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.
@@ -0,0 +1,186 @@
1
+ require 'amqp'
2
+ require 'securerandom'
3
+ require 'mercury/wire_serializer'
4
+ require 'mercury/received_message'
5
+ require 'logger'
6
+
7
+ class Mercury
8
+ attr_reader :amqp, :channel, :logger
9
+
10
+ def self.open(logger: Logger.new(STDOUT), **kws, &k)
11
+ @logger = logger
12
+ new(**kws, &k)
13
+ nil
14
+ end
15
+
16
+ def close(&k)
17
+ @amqp.close do
18
+ k.call
19
+ end
20
+ end
21
+
22
+ def initialize(host: 'localhost',
23
+ port: 5672,
24
+ vhost: '/',
25
+ username: 'guest',
26
+ password: 'guest',
27
+ &k)
28
+ AMQP.connect(host: host, port: port, vhost: vhost, username: username, password: password) do |amqp|
29
+ @amqp = amqp
30
+ @channel = AMQP::Channel.new(amqp, prefetch: 1) do
31
+ @channel.confirm_select
32
+ install_default_error_handler
33
+ k.call(self)
34
+ end
35
+ end
36
+ end
37
+ private_class_method :new
38
+
39
+ def publish(source_name, msg, tag: '', &k)
40
+ # The amqp gem caches exchange objects, so it's fine to
41
+ # redeclare the exchange every time we publish.
42
+ # TODO: wait for publish confirmations (@channel.on_ack)
43
+ with_source(source_name) do |exchange|
44
+ exchange.publish(write(msg), **Mercury.publish_opts(tag)) do
45
+ k.call
46
+ end
47
+ end
48
+ end
49
+
50
+ def self.publish_opts(tag)
51
+ { routing_key: tag, persistent: true }
52
+ end
53
+
54
+ def start_listener(source_name, handler, tag_filter: '#', &k)
55
+ with_source(source_name) do |exchange|
56
+ with_listener_queue(exchange, tag_filter) do |queue|
57
+ queue.subscribe(ack: false) do |metadata, payload|
58
+ handler.call(make_received_message(payload, metadata, false))
59
+ end
60
+ k.call
61
+ end
62
+ end
63
+ end
64
+
65
+ def start_worker(worker_group, source_name, handler, tag_filter: '#', &k)
66
+ with_source(source_name) do |exchange|
67
+ with_work_queue(worker_group, exchange, tag_filter) do |queue|
68
+ queue.subscribe(ack: true) do |metadata, payload|
69
+ handler.call(make_received_message(payload, metadata, true))
70
+ end
71
+ k.call
72
+ end
73
+ end
74
+ end
75
+
76
+ def delete_source(source_name, &k)
77
+ with_source(source_name) do |exchange|
78
+ exchange.delete do
79
+ k.call
80
+ end
81
+ end
82
+ end
83
+
84
+ def delete_work_queue(worker_group, &k)
85
+ @channel.queue(worker_group, work_queue_opts) do |queue|
86
+ queue.delete do
87
+ k.call
88
+ end
89
+ end
90
+ end
91
+
92
+ def source_exists?(source_name, &k)
93
+ existence_check(k) do |ch, &ret|
94
+ with_source_no_cache(ch, source_name, passive: true) do
95
+ ret.call(true)
96
+ end
97
+ end
98
+ end
99
+
100
+ def queue_exists?(queue_name, &k)
101
+ existence_check(k) do |ch, &ret|
102
+ ch.queue(queue_name, passive: true) do
103
+ ret.call(true)
104
+ end
105
+ end
106
+ end
107
+
108
+ private
109
+
110
+ def make_received_message(payload, metadata, is_ackable)
111
+ ReceivedMessage.new(read(payload), metadata, is_ackable: is_ackable)
112
+ end
113
+
114
+ def existence_check(k, &check)
115
+ AMQP::Channel.new(@amqp) do |ch|
116
+ ch.on_error do |_, info|
117
+ if info.reply_code == 404
118
+ # our request failed because it does not exist
119
+ k.call(false)
120
+ else
121
+ # failed for unknown reason
122
+ default_error_handler(ch, info)
123
+ end
124
+ end
125
+ check.call(ch) do |result|
126
+ ch.close do
127
+ k.call(result)
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def install_default_error_handler
134
+ @channel.on_error(&method(:default_error_handler))
135
+ end
136
+
137
+ def default_error_handler(_ch, info)
138
+ @amqp.close do
139
+ raise "An error occurred: #{info.reply_code} - #{info.reply_text}"
140
+ end
141
+ end
142
+
143
+ def write(msg)
144
+ WireSerializer.new.write(msg)
145
+ end
146
+
147
+ def read(bytes)
148
+ WireSerializer.new.read(bytes)
149
+ end
150
+
151
+ def with_source(source_name, &k)
152
+ with_source_no_cache(@channel, source_name, Mercury.source_opts) do |exchange|
153
+ k.call(exchange)
154
+ end
155
+ end
156
+
157
+ def with_source_no_cache(channel, source_name, opts, &k)
158
+ channel.topic(source_name, opts) do |*args|
159
+ k.call(*args)
160
+ end
161
+ end
162
+
163
+ def with_work_queue(worker_group, source_exchange, tag_filter, &k)
164
+ bind_queue(source_exchange, worker_group, tag_filter, work_queue_opts, &k)
165
+ end
166
+
167
+ def self.source_opts
168
+ { durable: true, auto_delete: false }
169
+ end
170
+
171
+ def work_queue_opts
172
+ { durable: true, auto_delete: false }
173
+ end
174
+
175
+ def with_listener_queue(source_exchange, tag_filter, &k)
176
+ bind_queue(source_exchange, '', tag_filter, exclusive: true, auto_delete: true, durable: false, &k)
177
+ end
178
+
179
+ def bind_queue(exchange, queue_name, tag_filter, opts, &k)
180
+ queue = @channel.queue(queue_name, opts)
181
+ queue.bind(exchange, routing_key: tag_filter) do
182
+ k.call(queue)
183
+ end
184
+ end
185
+
186
+ end
@@ -0,0 +1,41 @@
1
+ require 'mercury'
2
+ require 'mercury/cps'
3
+
4
+ class Mercury
5
+ class Monadic
6
+
7
+ def self.open(**kws)
8
+ Cps.new do |&k|
9
+ Mercury.open(**kws) do |m|
10
+ k.call(new(m))
11
+ end
12
+ end
13
+ end
14
+
15
+ def self.wrap(method_name)
16
+ define_method(method_name) do |*args, **kws, &block|
17
+ Cps.new do |&k|
18
+ if @mercury.method(method_name).parameters.map(&:first).include?(:key)
19
+ @mercury.send(method_name, *[*args, *block], **kws, &k)
20
+ else
21
+ @mercury.send(method_name, *[*args, *block], &k)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ wrap(:publish)
28
+ wrap(:start_listener)
29
+ wrap(:start_worker)
30
+ wrap(:delete_source)
31
+ wrap(:delete_work_queue)
32
+ wrap(:source_exists?)
33
+ wrap(:queue_exists?)
34
+ wrap(:close)
35
+
36
+ private
37
+ def initialize(mercury)
38
+ @mercury = mercury
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ class Mercury
2
+ class ReceivedMessage
3
+ attr_reader :content, :metadata
4
+
5
+ def initialize(content, metadata, is_ackable: false)
6
+ @content = content
7
+ @metadata = metadata
8
+ @is_ackable = is_ackable
9
+ end
10
+
11
+ def tag
12
+ metadata.routing_key
13
+ end
14
+
15
+ def ack
16
+ @is_ackable or raise 'This message is not ackable'
17
+ metadata.ack
18
+ end
19
+
20
+ def reject
21
+ @is_ackable or raise 'This message is not rejectable'
22
+ metadata.reject(requeue: false)
23
+ end
24
+
25
+ def nack
26
+ @is_ackable or raise 'This message is not nackable'
27
+ metadata.reject(requeue: true)
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,20 @@
1
+ require 'mercury/mercury'
2
+ require 'bunny'
3
+
4
+ class Mercury
5
+ class Sync
6
+ class << self
7
+ def publish(source_name, msg, tag: '', amqp_opts: {})
8
+ conn = Bunny.new(amqp_opts)
9
+ conn.start
10
+ ch = conn.create_channel
11
+ ch.confirm_select
12
+ ex = ch.topic(source_name, Mercury.source_opts)
13
+ ex.publish(WireSerializer.new.write(msg), **Mercury.publish_opts(tag))
14
+ ch.wait_for_confirms or raise 'failed to confirm publication'
15
+ ensure
16
+ conn.close
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,59 @@
1
+ require 'eventmachine'
2
+ require 'mercury/cps'
3
+ require 'mercury/monadic'
4
+
5
+ class Mercury
6
+ module TestUtils
7
+ def em
8
+ EM.run do
9
+ EM.add_timer(in_debug_mode? ? 999999 : 3) { raise 'EM spec timed out' }
10
+ yield
11
+ end
12
+ end
13
+
14
+ def in_debug_mode?
15
+ ENV['RUBYLIB'] =~ /ruby-debug-ide/ # http://stackoverflow.com/questions/22039807/determine-if-a-program-is-running-in-debug-mode
16
+ end
17
+
18
+ def done
19
+ EM.stop
20
+ end
21
+
22
+ def em_wait_until(pred, &k)
23
+ try_again = proc do
24
+ if pred.call
25
+ k.call
26
+ else
27
+ EM.add_timer(1.0 / 50, try_again)
28
+ end
29
+ end
30
+ try_again.call
31
+ end
32
+
33
+ def wait_until(&pred)
34
+ cps do |&k|
35
+ em_wait_until(pred, &k)
36
+ end
37
+ end
38
+
39
+ def wait_for(seconds)
40
+ cps do |&k|
41
+ EM.add_timer(seconds, &k)
42
+ end
43
+ end
44
+
45
+ def delete_sources_and_queues_cps(source_names, queue_names)
46
+ # We must create a new mercury. The AMQP gem doesn't let you redeclare
47
+ # a construct with the same instance you deleted it with.
48
+ Mercury::Monadic.open.and_then do |m|
49
+ Cps.inject(amq_filter(source_names)) { |s| m.delete_source(s) }.
50
+ inject(amq_filter(queue_names)) { |q| m.delete_work_queue(q) }.
51
+ and_then { m.close }
52
+ end
53
+ end
54
+
55
+ def amq_filter(xs)
56
+ xs.reject{|x| x.start_with?('amq.')}
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,9 @@
1
+ class Utils
2
+ def self.unsplat(args)
3
+ if args.size == 1 and args.first.is_a?(Array)
4
+ args.first
5
+ else
6
+ args
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,3 @@
1
+ class Mercury
2
+ VERSION = '0.1.0'
3
+ end
@@ -0,0 +1,54 @@
1
+ require 'oj'
2
+
3
+ class Mercury
4
+ class WireSerializer
5
+ # TODO: DRY with hyperion once we know more
6
+
7
+ def write(struct_or_hash)
8
+ write_json(struct_or_hash)
9
+ end
10
+
11
+ def read(bytes)
12
+ read_json(bytes)
13
+ end
14
+
15
+ private
16
+
17
+ def write_json(obj)
18
+ if obj.is_a?(String)
19
+ obj
20
+ else
21
+ Oj.dump(hashify(obj), oj_options)
22
+ end
23
+ end
24
+
25
+ def read_json(bytes)
26
+ begin
27
+ Oj.compat_load(bytes, oj_options)
28
+ rescue Oj::ParseError => e
29
+ bytes
30
+ end
31
+ end
32
+
33
+ def oj_options
34
+ {
35
+ mode: :compat,
36
+ time_format: :xmlschema, # xmlschema == iso8601
37
+ use_to_json: false,
38
+ second_precision: 3
39
+ }
40
+ end
41
+
42
+ def hashify(x)
43
+ case x
44
+ when Hash
45
+ x
46
+ when Struct
47
+ x.to_h
48
+ else
49
+ raise "Could not convert to hash: #{x.inspect}"
50
+ end
51
+ end
52
+
53
+ end
54
+ end
data/lib/mercury.rb ADDED
@@ -0,0 +1 @@
1
+ require 'mercury/mercury'
@@ -0,0 +1,33 @@
1
+ # coding: utf-8
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'mercury/version'
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = 'mercury_amqp'
8
+ spec.version = Mercury::VERSION
9
+ spec.authors = ['Peter Winton']
10
+ spec.email = ['wintonpc@gmail.com']
11
+ spec.summary = 'AMQP-backed messaging layer'
12
+ spec.description = 'Abstracts common patterns used with AMQP'
13
+ spec.homepage = 'https://github.com/wintonpc/mercury_amqp'
14
+ spec.license = 'MIT'
15
+
16
+ spec.files = `git ls-files -z`.split("\x0")
17
+ spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
+ spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
19
+ spec.require_paths = ['lib']
20
+
21
+ spec.add_development_dependency 'bundler', '~> 1.7'
22
+ spec.add_development_dependency 'rake', '~> 10.0'
23
+ spec.add_development_dependency 'yard'
24
+ spec.add_development_dependency 'rspec', '~> 3.0'
25
+ spec.add_development_dependency 'json_spec'
26
+ spec.add_development_dependency 'evented-spec'
27
+ spec.add_development_dependency 'rspec_junit_formatter'
28
+
29
+ spec.add_runtime_dependency 'oj', '~> 2.12'
30
+ spec.add_runtime_dependency 'amqp', '~> 1.5'
31
+ spec.add_runtime_dependency 'bunny', '~> 2.1'
32
+ spec.add_runtime_dependency 'binding_of_caller', '~> 0.7'
33
+ end
@@ -0,0 +1,225 @@
1
+ require 'spec_helper'
2
+ require 'mercury/cps'
3
+
4
+ describe Cps do
5
+ include Cps::Methods
6
+
7
+ let!(:a) { 123 }
8
+
9
+ describe '::lift' do
10
+ it 'CPS-transforms a non-CPS proc' do
11
+ expect(Cps.lift{rand}.run).to be_a Numeric
12
+ end
13
+ end
14
+
15
+ describe '::run' do
16
+ it 'passes the value to the continuation' do
17
+ expect{|b| lift{a}.run(&b)}.to yield_with_args(a)
18
+ end
19
+ it 'returns the return value of the continuation' do
20
+ expect(lift{a}.run{456}).to eql 456
21
+ end
22
+ it 'feeds its arguments into the Cps' do
23
+ expect{|b| Cps.identity.run(a, &b)}.to yield_with_args(a)
24
+ end
25
+ end
26
+
27
+ describe '#and_then' do
28
+ it 'composes two Cps instances' do
29
+ expect(lift{a}.and_then{|x| to_string(x)}.run).to eql a.to_s
30
+ end
31
+ end
32
+
33
+ describe '#and_lift' do
34
+ it 'composes a Cps instance with a normal proc' do
35
+ expect(lift{a}.and_lift{|x| x.to_s}.run).to eql a.to_s
36
+ end
37
+ end
38
+
39
+ describe '::concurrently' do
40
+ it 'composes Cps instances concurrently' do
41
+ em do
42
+ actions = []
43
+ started1 = false
44
+ finished2 = false
45
+
46
+ task1 = proc do |n, &k|
47
+ actions << 'start1'
48
+ started1 = true
49
+ em_wait_until(proc{finished2}) do
50
+ actions << 'finish1'
51
+ k.call(n.to_s)
52
+ end
53
+ end
54
+ task2 = proc do |n, &k|
55
+ em_wait_until(proc{started1}) do
56
+ actions << 'start2'
57
+ actions << 'finish2'
58
+ finished2 = true
59
+ k.call(-1 * n)
60
+ end
61
+ end
62
+
63
+ result = nil
64
+ Cps.concurrently(Cps.new(&task1), Cps.new(&task2)).run(42) { |r| result = r }
65
+ em_wait_until(proc{result}) do
66
+ expect(result).to eql [['42'], [-42]]
67
+ expect(actions).to eql %w(start1 start2 finish2 finish1)
68
+ done
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ describe '::seq' do
75
+ it 'binds a sequence of monadic functions' do
76
+ result = Cps.seq do |th|
77
+ th.en { to_string(a) }
78
+ th.en { |v| twice(v) }
79
+ th.en { |v| reverse(v) }
80
+ end.run
81
+ expect(result).to eql '321321'
82
+ end
83
+ it 'can be chained' do
84
+ result = Cps.seq do |th|
85
+ th.en { to_string(a) }
86
+ th.en { |v| twice(v) }
87
+ th.en do |v|
88
+ Cps.seq do |th|
89
+ th.en { reverse(v) }
90
+ th.en { |v| surround(v) }
91
+ end
92
+ end
93
+ end.run
94
+ expect(result).to eql '*321321*'
95
+ end
96
+ end
97
+
98
+ describe '::seql' do
99
+ it 'binds a sequence of monadic functions' do
100
+ result = Cps.seql do
101
+ let(:v) { to_string(a) }
102
+ let(:v) { twice(v) }
103
+ and_then { reverse(v) }
104
+ end.run
105
+ expect(result).to eql '321321'
106
+ end
107
+ it 'can be chained' do
108
+ result = Cps.seql do
109
+ let(:v) { to_string(a) }
110
+ let(:v) { twice(v) }
111
+ and_then do
112
+ Cps.seql do
113
+ let(:v) { reverse(v) }
114
+ and_then { surround(v) }
115
+ end
116
+ end
117
+ end.run
118
+ expect(result).to eql '*321321*'
119
+ end
120
+ it 'can be used like seq' do
121
+ result = Cps.seql do
122
+ and_then { to_string(a) }
123
+ and_then { |v| twice(v) }
124
+ and_then { |v| reverse(v) }
125
+ end.run
126
+ expect(result).to eql '321321'
127
+ end
128
+ it 'passes a bound value to the next function' do
129
+ result = Cps.seql do
130
+ let(:v) { to_string(a) }
131
+ and_then { |x| lift{x} }
132
+ end.run
133
+ expect(result).to eql '123'
134
+ end
135
+ it 'block can access outer methods' do
136
+ Cps.seql do
137
+ expect(foo).to eql 'foo'
138
+ end
139
+ end
140
+ def foo
141
+ 'foo'
142
+ end
143
+ it 'block can access outer variables' do
144
+ bar = 'bar'
145
+ Cps.seql do
146
+ expect(bar).to eql 'bar'
147
+ end
148
+ end
149
+ it 'block cannot access outer instance variables' do
150
+ @baz = 'baz'
151
+ Cps.seql do
152
+ expect(@baz).to eql nil
153
+ end
154
+ end
155
+ it 'block can access outer constants' do
156
+ Cps.seql do
157
+ expect(FLIP).to eql 'flip'
158
+ end
159
+ end
160
+ FLIP = 'flip'
161
+ end
162
+
163
+ describe '::identity' do
164
+ it 'passes its arguments to the continuation' do
165
+ expect{|b| Cps.identity.run(a, &b)}.to yield_with_args(a)
166
+ end
167
+ end
168
+
169
+ describe '#inject' do
170
+ it 'creates a Cps chain given an array and a transformation function' do
171
+ chain = lift{'entity'}.inject(['-ize', '-er']) do |suffix, v|
172
+ lift { v + suffix }
173
+ end
174
+ expect(chain.run).to eql 'entity-ize-er'
175
+ end
176
+ end
177
+
178
+ # verify Cps obeys the monad laws (https://wiki.haskell.org/Monad_laws)
179
+ # unit ::= Cps::lift
180
+ # bind ::= Cps#and_then
181
+
182
+ it 'obeys the left identity law' do
183
+ # return a >>= f === f a
184
+ expect_identical(lift{a}.and_then(&method(:to_string)),
185
+ to_string(a),
186
+ '123')
187
+ end
188
+
189
+ it 'obeys the right identity law' do
190
+ # m >>= return === m
191
+ m = lift{123}
192
+ expect_identical(m.and_then{|x| lift{x}},
193
+ m,
194
+ 123)
195
+ end
196
+
197
+ it 'obeys the associativity law' do
198
+ # (m >>= f) >>= g === m >>= (\x -> f x >>= g)
199
+ m = lift{123}
200
+ expect_identical((m.and_then(&method(:to_string))).and_then(&method(:twice)),
201
+ m.and_then {|x| to_string(x).and_then(&method(:twice)) },
202
+ '123123')
203
+ end
204
+
205
+ def expect_identical(lhs, rhs, expected_value)
206
+ expect{|b| lhs.run(&b)}.to yield_with_args(expected_value)
207
+ expect{|b| rhs.run(&b)}.to yield_with_args(expected_value)
208
+ end
209
+
210
+ def to_string(x)
211
+ lift { x.to_s }
212
+ end
213
+
214
+ def twice(x)
215
+ lift { x * 2 }
216
+ end
217
+
218
+ def reverse(x)
219
+ lift { x.reverse }
220
+ end
221
+
222
+ def surround(x)
223
+ lift { "*#{x}*" }
224
+ end
225
+ end