mercury_amqp 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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