mercury_amqp 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 22461cdd32d965f465191c9f4270ea23724d9d14
4
+ data.tar.gz: 4934514f73a40ee799e174eba6dabe11add998c4
5
+ SHA512:
6
+ metadata.gz: de0a9fa90882b011bdd0011055f2546ad1a7b6d0ed9deadc5e0c7c3c7f759a5824267fa9e6c9c75a834b23f3413be306de6b303766c5e024af32a85f4c750225
7
+ data.tar.gz: 639003755c8e26bbdcf98b66178d1d32cd91a2c8b7ba4b6efb1a98996915a7715381077d06f895d892358edf719e5975da7ae34e77401cb531d62b85a4447a8b
data/.gitignore ADDED
@@ -0,0 +1,4 @@
1
+ .idea
2
+ .bundle
3
+ Gemfile.lock
4
+ *~
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --color
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in hyperion.gemspec
4
+ gemspec
5
+
data/README.md ADDED
@@ -0,0 +1,85 @@
1
+ mercury
2
+ =======
3
+
4
+ Mercury is a messaging layer intended to hide complexity for typical
5
+ messaging scenarios. It is backed by the AMQP gem and consequently
6
+ runs in an EventMachine reactor and has an asynchronous API. Mercury
7
+ consists of _sources_, _work queues_, and _listeners_. A message is
8
+ published to a source, to which one or more work queues and/or
9
+ listeners are attached. These map roughly to AMQP constructs:
10
+
11
+ - **source**: topic exchange
12
+ - **work queue**: durable named queue
13
+ - **listener**: temporary anonymous queue
14
+ - **tag**: routing key
15
+
16
+ At the moment, mercury is backed by AMQP and serializes messages as
17
+ JSON. In the future, additional transports and message formats may be
18
+ supported.
19
+
20
+ Mercury writes string messages directly without encoding; this allows
21
+ a client to pre-encode a message using an arbitrary encoding. The
22
+ receiving client receives the encoded bytes as the message content
23
+ (assuming the encoded message fails to parse as JSON).
24
+
25
+
26
+ ```ruby
27
+ require 'mercury'
28
+
29
+ def run
30
+ EventMachine.run do
31
+ Mercury.open do |m|
32
+ m.start_worker('cooks', 'orders', method(:handle_message)) do
33
+ m.publish('orders', {'table' => 5, 'items' => ['salad', 'steak', 'cake']})
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ def handle_message(msg)
40
+ order = msg.content
41
+ cook(order)
42
+ msg.ack
43
+ end
44
+ ```
45
+
46
+ Notably, mercury also has a monadic interface that hides the explicit
47
+ continuation passing introduced by asynchrony, which has the effect of
48
+ flattening chained calls. This is particularly useful for testing,
49
+ where the same code plays both sides of a conversation. Compare:
50
+
51
+ ```ruby
52
+ require 'mercury'
53
+
54
+ Mercury.open do |m|
55
+ m.start_listener(source, proc{}) do
56
+ m.source_exists?(source) do |r1|
57
+ expect(r1).to be true
58
+ m.delete_source(source) do
59
+ m.source_exists?(source) do |r2|
60
+ expect(r2).to be false
61
+ m.close do
62
+ done
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ # ... vs ...
71
+
72
+ require 'mercury/monadic'
73
+
74
+ seq do
75
+ let(:m) { Mercury::Monadic.open }
76
+ and_then { m.start_listener(source) }
77
+ let(:r1) { m.source_exists?(source) }
78
+ and_lift { expect(r1).to be true }
79
+ and_then { m.delete_source(source) }
80
+ let(:r2) { m.source_exists?(source) }
81
+ and_lift { expect(r2).to be false }
82
+ and_then { m.close }
83
+ and_lift { done }
84
+ end
85
+ ```
data/Rakefile ADDED
@@ -0,0 +1,9 @@
1
+ begin
2
+ require 'rspec/core/rake_task'
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task :default => :spec
7
+ rescue LoadError
8
+ # no rspec available
9
+ end
@@ -0,0 +1,23 @@
1
+ class Cps
2
+ module Methods
3
+ def lift(&block)
4
+ Cps.lift(&block)
5
+ end
6
+
7
+ def seq(&block)
8
+ Cps.seq(&block)
9
+ end
10
+
11
+ def seql(&block)
12
+ Cps.seql(2, &block)
13
+ end
14
+
15
+ def seqp(&block)
16
+ Cps.seqp(&block)
17
+ end
18
+
19
+ def cps(&block)
20
+ Cps.new(&block)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ class Cps
2
+
3
+ # Syntactic sugar for and_then chains.
4
+ def self.seq(&block)
5
+ s = Seq.new
6
+ block.call(s.method(:chain))
7
+ s.m
8
+ end
9
+
10
+ class Seq
11
+ def m
12
+ @m ||= Cps.identity # we need an initial Cps to chain onto
13
+ end
14
+
15
+ def chain(proc=nil, &block)
16
+ @m = m.and_then(&(proc || block))
17
+ end
18
+ end
19
+ end
20
+
21
+ class Method
22
+ alias_method :en, :call # to be called `th.en`
23
+ end
@@ -0,0 +1,67 @@
1
+ require 'binding_of_caller'
2
+
3
+ class Cps
4
+ # Syntactic sugar for and_then chains.
5
+ def self.seql(depth=1, &block)
6
+ # EXPERIMENTAL
7
+ # The trick here is to execute the block in a context where
8
+ # 1. we can simulate local let-bound variables, and
9
+ # 2. the block can access variables and methods available
10
+ # outside the call to seql.
11
+ #
12
+ # To achieve this, we instance_exec the block in a SeqWithLet
13
+ # object, which provides the let bound variables (as methods)
14
+ # and uses method_missing to proxy other methods to the parent
15
+ # binding.
16
+ #
17
+ # Note: parent instance variables are not available inside the block.
18
+ # Note: keyword arguments are not proxied to methods called in the parent binding
19
+ context = SeqWithLet.new(binding.of_caller(depth))
20
+ context.instance_exec(&block)
21
+ context.__chain
22
+ end
23
+
24
+ class SeqWithLet
25
+
26
+ def and_then(&block)
27
+ @__chain = __chain.and_then(&block)
28
+ end
29
+
30
+ def and_lift(&block)
31
+ @__chain = __chain.and_lift(&block)
32
+ end
33
+
34
+ def let(name, &block)
35
+ and_then(&block)
36
+ and_then do |value|
37
+ __values[name] = value
38
+ Cps.lift{value}
39
+ end
40
+ end
41
+
42
+ def initialize(parent_binding)
43
+ @__parent_binding = parent_binding
44
+ end
45
+
46
+ def __chain
47
+ @__chain ||= Cps.identity
48
+ end
49
+
50
+ def method_missing(name, *args, &block)
51
+ __values.fetch(name) { __parent_call(name.to_s, *args, &block) }
52
+ end
53
+
54
+ def __parent_call(name, *args, &block)
55
+ @__parent_caller ||= @__parent_binding.eval <<-EOD
56
+ proc do |name, *args, &block|
57
+ send(name, *args, &block)
58
+ end
59
+ EOD
60
+ @__parent_caller.call(name, *args, &block)
61
+ end
62
+
63
+ def __values
64
+ @__values ||= {}
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,97 @@
1
+ require 'mercury/cps/seq'
2
+ require 'mercury/cps/seq_with_let'
3
+ require 'mercury/cps/methods'
4
+ require 'mercury/utils'
5
+
6
+ # Async IO often involves CPS (continuation-passing style)
7
+ # code, where the continuation (a.k.a. "callback") is passed
8
+ # to a function to be invoked at a later time. CPS style
9
+ # can result in deep lexical nesting making code difficult
10
+ # to read.
11
+ # This monad hides the CPS plumbing, which allows code
12
+ # to be written in a flat style with no visible continuation
13
+ # passing. It basically wraps a CPS proc with methods for
14
+ # composing them.
15
+ # See http://codon.com/refactoring-ruby-with-monads
16
+ class Cps
17
+ attr_reader :cps
18
+
19
+ # @param [Proc] cps a CPS proc (signature: *args, &k)
20
+ def initialize(&cps)
21
+ @cps = cps
22
+ end
23
+
24
+ # Applies the wrapped proc. If the CPS return value
25
+ # is not needed, the continuation k may be omitted.
26
+ # Returns the return value of the continuation.
27
+ def run(*args, &k)
28
+ k ||= proc { |x| x }
29
+ cps.call(*args, &k)
30
+ end
31
+
32
+ # The "bind" operation; composes two Cps
33
+ # @param [Proc] pm a proc that takes the output of this
34
+ # Cps and returns a Cps
35
+ def and_then(&pm)
36
+ Cps.new do |*args, &k|
37
+ self.run(*args) do |*args2|
38
+ next_cps = pm.call(*args2)
39
+ next_cps.is_a?(Cps) or raise "'and_then' block did not return a Cps. Did you want 'and_lift'? at #{pm.source_location}"
40
+ next_cps.run(&k)
41
+ end
42
+ end
43
+ end
44
+
45
+ # equivalent to: and_then { lift { ... } }
46
+ def and_lift(&p)
47
+ and_then do |*args|
48
+ Cps.lift { p.call(*args) }
49
+ end
50
+ end
51
+
52
+ # Returns a Cps for a non-CPS proc.
53
+ def self.lift(&p)
54
+ new { |*args, &k| k.call(p.call(*args)) }
55
+ end
56
+
57
+ # The identity function as a Cps.
58
+ def self.identity
59
+ new { |*args, &k| k.call(*args) }
60
+ end
61
+
62
+ # Returns a Cps that executes the provided Cpses concurrently.
63
+ # Once all complete, their return values are passed to the continuation
64
+ # in an array with positions corresponding to the provided Cpses.
65
+ def self.concurrently(*cpss)
66
+ cpss = Utils.unsplat(cpss)
67
+
68
+ Cps.new do |*in_args, &k|
69
+ pending_completions = cpss
70
+ returned_args = []
71
+ cpss.each_with_index do |cps, i|
72
+ cps.run(*in_args) do |*out_args|
73
+ returned_args[i] = out_args
74
+ pending_completions.delete(cps)
75
+ if pending_completions.none?
76
+ k.call(returned_args)
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+
83
+ # Calls and_then for each x.
84
+ # @yieldparam x [Object] An item from xs
85
+ # @yieldparam *args [Objects] The value(s) passed from the last action
86
+ # @yieldreturn [Cps] The next action to add to the chain
87
+ def inject(xs, &block)
88
+ xs.inject(self) do |chain, x|
89
+ chain.and_then { |*args| block.call(x, *args) }
90
+ end
91
+ end
92
+
93
+ # equivalent to Cps.identity.inject(...)
94
+ def self.inject(xs, &block)
95
+ Cps.identity.inject(xs, &block)
96
+ end
97
+ end
@@ -0,0 +1,9 @@
1
+ class Mercury
2
+ class Fake
3
+ class Domain
4
+ def queues
5
+ @queues ||= {}
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,24 @@
1
+ class Mercury
2
+ class Fake
3
+ class Metadata
4
+ def initialize(tag, dequeue, requeue)
5
+ @tag = tag
6
+ @dequeue = dequeue
7
+ @requeue = requeue
8
+ end
9
+
10
+ def routing_key
11
+ @tag
12
+ end
13
+
14
+ def ack
15
+ @dequeue.call
16
+ end
17
+
18
+ def reject(opts)
19
+ requeue = opts[:requeue]
20
+ requeue ? @requeue.call : @dequeue.call
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,80 @@
1
+ require 'eventmachine'
2
+ require 'mercury/fake/queued_message'
3
+
4
+ class Mercury
5
+ class Fake
6
+ class Queue
7
+ attr_reader :source, :worker
8
+
9
+ def initialize(source, tag_filter, worker, require_ack)
10
+ @source = source
11
+ @tag_filter = tag_filter
12
+ @worker = worker
13
+ @require_ack = require_ack
14
+ @msgs = []
15
+ @subscribers = []
16
+ end
17
+
18
+ def add_subscriber(s)
19
+ subscribers << s
20
+ deliver # new subscriber probably wants a message
21
+ end
22
+
23
+ def enqueue(msg, tag)
24
+ msgs.push(QueuedMessage.new(self, msg, tag, @require_ack))
25
+ deliver # new message. someone probably wants it.
26
+ end
27
+
28
+ def ack_or_reject_message(msg)
29
+ msgs.delete(msg) or raise 'tried to delete message that was not in queue!!'
30
+ msg.subscriber.busy = false
31
+ deliver # a subscriber just freed up
32
+ end
33
+
34
+ def nack(msg)
35
+ msg.delivered = false
36
+ msg.subscriber.busy = false
37
+ deliver
38
+ end
39
+
40
+ def binds?(source_name, tag)
41
+ source_name == source && tag_match?(tag_filter, tag)
42
+ end
43
+
44
+ private
45
+ attr_reader :msgs, :subscribers, :tag_filter
46
+
47
+ def tag_match?(filter, tag)
48
+ # for wildcard description, see https://www.rabbitmq.com/tutorials/tutorial-five-python.html
49
+ pattern = Regexp.new(filter.gsub('*', '[^\.]+').gsub('#', '.*?'))
50
+ pattern.match(tag)
51
+ end
52
+
53
+ def deliver
54
+ EM.next_tick do
55
+ if idle_subscribers.any? && undelivered.any?
56
+ msg = undelivered.first
57
+ subscriber = idle_subscribers.sample
58
+ if @require_ack
59
+ msg.delivered = true
60
+ subscriber.busy = true
61
+ else
62
+ msgs.delete(msg)
63
+ end
64
+ msg.subscriber = subscriber
65
+ subscriber.handler.call(msg.received_msg)
66
+ deliver # continue delivering
67
+ end
68
+ end
69
+ end
70
+
71
+ def undelivered
72
+ msgs.reject(&:delivered)
73
+ end
74
+
75
+ def idle_subscribers
76
+ subscribers.reject(&:busy)
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,17 @@
1
+ require 'mercury/fake/metadata'
2
+ require 'mercury/received_message'
3
+
4
+ class Mercury
5
+ class Fake
6
+ class QueuedMessage
7
+ attr_reader :received_msg
8
+ attr_accessor :delivered, :subscriber
9
+
10
+ def initialize(queue, msg, tag, is_ackable)
11
+ metadata = Metadata.new(tag, proc{queue.ack_or_reject_message(self)}, proc{queue.nack(self)})
12
+ @received_msg = ReceivedMessage.new(msg, metadata, is_ackable: is_ackable)
13
+ @delivered = false
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,13 @@
1
+ class Mercury
2
+ class Fake
3
+ class Subscriber
4
+ attr_reader :handler
5
+ attr_accessor :busy
6
+
7
+ def initialize(handler)
8
+ @handler = handler
9
+ @busy = false
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,104 @@
1
+ require 'securerandom'
2
+ require 'delegate'
3
+ require 'mercury/received_message'
4
+ require 'mercury/fake/domain'
5
+ require 'mercury/fake/metadata'
6
+ require 'mercury/fake/queue'
7
+ require 'mercury/fake/queued_message'
8
+ require 'mercury/fake/subscriber'
9
+
10
+ # This class simulates Mercury without using the AMQP gem.
11
+ # It can be useful for unit testing code that uses Mercury.
12
+ # The domain concept allows different mercury instances to
13
+ # hit different virtual servers; this should rarely be needed.
14
+ # This class cannot simulate behavior of server disconnections,
15
+ # broken sockets, etc.
16
+ class Mercury
17
+ class Fake
18
+ def initialize(domain=:default)
19
+ @domain = Fake.domains[domain]
20
+ end
21
+
22
+ def self.domains
23
+ @domains ||= Hash.new { |h, k| h[k] = Domain.new }
24
+ end
25
+
26
+ def close(&k)
27
+ @closed = true
28
+ ret(k)
29
+ end
30
+
31
+ def publish(source_name, msg, tag: '', &k)
32
+ assert_not_closed
33
+ queues.values.select{|q| q.binds?(source_name, tag)}.each{|q| q.enqueue(roundtrip(msg), tag)}
34
+ ret(k)
35
+ end
36
+
37
+ def start_listener(source_name, handler, tag_filter: '#', &k)
38
+ start_worker_or_listener(source_name, handler, tag_filter, &k)
39
+ end
40
+
41
+ def start_worker(worker_group, source_name, handler, tag_filter: '#', &k)
42
+ start_worker_or_listener(source_name, handler, tag_filter, worker_group, &k)
43
+ end
44
+
45
+ def start_worker_or_listener(source_name, handler, tag_filter, worker_group=nil, &k)
46
+ assert_not_closed
47
+ q = ensure_queue(source_name, tag_filter, !!worker_group, worker_group)
48
+ ret(k) # it's important we show the "start" operation finishing before delivery starts (in add_subscriber)
49
+ q.add_subscriber(Subscriber.new(handler))
50
+ end
51
+ private :start_worker_or_listener
52
+
53
+ def delete_source(source_name, &k)
54
+ assert_not_closed
55
+ queues.delete_if{|_k, v| v.source == source_name}
56
+ ret(k)
57
+ end
58
+
59
+ def delete_work_queue(worker_group, &k)
60
+ assert_not_closed
61
+ queues.delete_if{|_k, v| v.worker == worker_group}
62
+ ret(k)
63
+ end
64
+
65
+ def source_exists?(source, &k)
66
+ built_in_sources = %w(direct topic fanout headers match rabbitmq.log rabbitmq.trace).map{|x| "amq.#{x}"}
67
+ ret(k, (queues.values.map(&:source) + built_in_sources).include?(source))
68
+ end
69
+
70
+ def queue_exists?(worker, &k)
71
+ ret(k, queues.values.map(&:worker).include?(worker))
72
+ end
73
+
74
+ private
75
+
76
+ def queues
77
+ @domain.queues
78
+ end
79
+
80
+ def ret(k, value=nil)
81
+ EM.next_tick{k.call(value)} if k
82
+ end
83
+
84
+ def roundtrip(msg)
85
+ ws = WireSerializer.new
86
+ ws.read(ws.write(msg))
87
+ end
88
+
89
+ def ensure_queue(source, tag_filter, require_ack, worker=nil)
90
+ worker ||= SecureRandom.uuid
91
+ queues.fetch(unique_queue_name(source, tag_filter, worker)) do |k|
92
+ queues[k] = Queue.new(source, tag_filter, worker, require_ack)
93
+ end
94
+ end
95
+
96
+ def unique_queue_name(source, tag_filter, worker)
97
+ [source, tag_filter, worker].join('^')
98
+ end
99
+
100
+ def assert_not_closed
101
+ raise 'connection is closed' if @closed
102
+ end
103
+ end
104
+ end