freddy 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ class Freddy
2
+ class Delivery
3
+ attr_reader :info, :properties
4
+
5
+ def initialize(info, properties)
6
+ @info = info
7
+ @properties = properties
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,20 @@
1
+ class Freddy
2
+ class MessageHandler
3
+ attr_reader :destination, :correlation_id
4
+
5
+ def initialize(adapter, delivery)
6
+ @adapter = adapter
7
+ @properties = delivery.properties
8
+ @destination = @properties[:destination]
9
+ @correlation_id = @properties[:correlation_id]
10
+ end
11
+
12
+ def success(response = nil)
13
+ @adapter.success(@properties[:reply_to], response)
14
+ end
15
+
16
+ def error(error = {error: "Couldn't process message"})
17
+ @adapter.error(@properties[:reply_to], error)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,67 @@
1
+ class Freddy
2
+ module MessageHandlers
3
+ def self.for_type(type)
4
+ type == 'request' ? RequestHandler : StandardMessageHandler
5
+ end
6
+
7
+ class StandardMessageHandler
8
+ def initialize(producer, logger)
9
+ @producer = producer
10
+ @logger = logger
11
+ end
12
+
13
+ def handle_message(payload, msg_handler, &block)
14
+ block.call payload, msg_handler
15
+ rescue Exception => e
16
+ destination = msg_handler.destination
17
+ @logger.error "Exception occured while processing message from #{destination}: #{Freddy.format_exception(e)}"
18
+ Freddy.notify_exception(e, destination: destination)
19
+ end
20
+
21
+ def success(*)
22
+ # NOP
23
+ end
24
+
25
+ def error(*)
26
+ # NOP
27
+ end
28
+ end
29
+
30
+ class RequestHandler
31
+ def initialize(producer, logger)
32
+ @producer = producer
33
+ @logger = logger
34
+ end
35
+
36
+ def handle_message(payload, msg_handler, &block)
37
+ @correlation_id = msg_handler.correlation_id
38
+
39
+ if !@correlation_id
40
+ @logger.error "Received request without correlation_id"
41
+ Freddy.notify_exception(e)
42
+ else
43
+ block.call payload, msg_handler
44
+ end
45
+ rescue Exception => e
46
+ @logger.error "Exception occured while handling the request with correlation_id #{@correlation_id}: #{Freddy.format_exception(e)}"
47
+ Freddy.notify_exception(e, destination: msg_handler.destination, correlation_id: @correlation_id)
48
+ end
49
+
50
+ def success(reply_to, response)
51
+ send_response(reply_to, response, type: 'success')
52
+ end
53
+
54
+ def error(reply_to, response)
55
+ send_response(reply_to, response, type: 'error')
56
+ end
57
+
58
+ private
59
+
60
+ def send_response(reply_to, response, opts = {})
61
+ @producer.produce reply_to.force_encoding('utf-8'), response, {
62
+ correlation_id: @correlation_id
63
+ }.merge(opts)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,28 @@
1
+ require_relative 'request'
2
+ require 'json'
3
+
4
+ class Freddy
5
+ class Producer
6
+ CONTENT_TYPE = 'application/json'.freeze
7
+
8
+ def initialize(channel, logger)
9
+ @channel, @logger = channel, logger
10
+ @exchange = @channel.default_exchange
11
+ @topic_exchange = @channel.topic Freddy::FREDDY_TOPIC_EXCHANGE_NAME
12
+ end
13
+
14
+ def produce(destination, payload, properties={})
15
+ @logger.debug "Producing message #{payload.inspect} to #{destination}"
16
+
17
+ properties = properties.merge(routing_key: destination, content_type: CONTENT_TYPE)
18
+ json_payload = payload.to_json
19
+
20
+ @topic_exchange.publish json_payload, properties.dup
21
+ @exchange.publish json_payload, properties.dup
22
+ end
23
+
24
+ def on_return(*args, &block)
25
+ @exchange.on_return(*args, &block)
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,106 @@
1
+ require_relative 'producer'
2
+ require_relative 'consumer'
3
+ require_relative 'request_manager'
4
+ require_relative 'sync_response_container'
5
+ require_relative 'message_handlers'
6
+ require 'securerandom'
7
+ require 'hamster/mutable_hash'
8
+
9
+ class Freddy
10
+ class Request
11
+ NO_ROUTE = 312
12
+
13
+ class EmptyRequest < Exception
14
+ end
15
+
16
+ class EmptyResponder < Exception
17
+ end
18
+
19
+ def initialize(channel, logger)
20
+ @channel, @logger = channel, logger
21
+ @producer, @consumer = Producer.new(channel, logger), Consumer.new(channel, logger)
22
+ @listening_for_responses = false
23
+ @request_map = Hamster.mutable_hash
24
+ @request_manager = RequestManager.new @request_map, @logger
25
+
26
+ @producer.on_return do |return_info, properties, content|
27
+ if return_info[:reply_code] == NO_ROUTE
28
+ @request_manager.no_route(properties[:correlation_id])
29
+ end
30
+ end
31
+ end
32
+
33
+ def sync_request(destination, payload, opts)
34
+ timeout_seconds = opts.fetch(:timeout)
35
+ container = SyncResponseContainer.new
36
+ async_request destination, payload, opts, &container
37
+ container.wait_for_response(timeout_seconds + 0.1)
38
+ end
39
+
40
+ def async_request(destination, payload, timeout:, delete_on_timeout:, **options, &block)
41
+ listen_for_responses unless @listening_for_responses
42
+
43
+ correlation_id = SecureRandom.uuid
44
+ @request_map.store(correlation_id, callback: block, destination: destination, timeout: Time.now + timeout)
45
+
46
+ @logger.debug "Publishing request to #{destination}, waiting for response on #{@response_queue.name} with correlation_id #{correlation_id}"
47
+
48
+ if delete_on_timeout
49
+ options[:expiration] = (timeout * 1000).to_i
50
+ end
51
+
52
+ @producer.produce destination, payload, options.merge(
53
+ correlation_id: correlation_id, reply_to: @response_queue.name,
54
+ mandatory: true, type: 'request'
55
+ )
56
+ end
57
+
58
+ def respond_to(destination, &block)
59
+ raise EmptyResponder unless block
60
+ @response_queue = create_response_queue unless @response_queue
61
+ @logger.debug "Listening for requests on #{destination}"
62
+
63
+ responder_handler = @consumer.consume destination do |payload, delivery|
64
+ handler = MessageHandlers.for_type(delivery.properties[:type]).new(@producer, @logger)
65
+
66
+ msg_handler = MessageHandler.new(handler, delivery)
67
+ handler.handle_message payload, msg_handler, &block
68
+ end
69
+ responder_handler
70
+ end
71
+
72
+ private
73
+
74
+ def create_response_queue
75
+ @channel.queue("", exclusive: true)
76
+ end
77
+
78
+ def handle_response(payload, delivery)
79
+ correlation_id = delivery.properties[:correlation_id]
80
+ request = @request_map[correlation_id]
81
+ if request
82
+ @logger.debug "Got response for request to #{request[:destination]} with correlation_id #{correlation_id}"
83
+ @request_map.delete correlation_id
84
+ request[:callback].call payload, delivery
85
+ else
86
+ message = "Got rpc response for correlation_id #{correlation_id} but there is no requester"
87
+ @logger.warn message
88
+ Freddy.notify 'NoRequesterForResponse', message, correlation_id: correlation_id
89
+ end
90
+ rescue Exception => e
91
+ destination_report = request ? "to #{request[:destination]}" : ''
92
+ @logger.error "Exception occured while handling the response of request made #{destination_report} with correlation_id #{correlation_id}: #{Freddy.format_exception e}"
93
+ Freddy.notify_exception(e, destination: request[:destination], correlation_id: correlation_id)
94
+ end
95
+
96
+ def listen_for_responses
97
+ @listening_for_responses = true
98
+ @response_queue = create_response_queue unless @response_queue
99
+ @request_manager.start
100
+ @consumer.consume_from_queue @response_queue do |payload, delivery|
101
+ handle_response payload, delivery
102
+ end
103
+ end
104
+
105
+ end
106
+ end
@@ -0,0 +1,41 @@
1
+ class Freddy
2
+ class RequestManager
3
+
4
+ def initialize(requests, logger)
5
+ @requests, @logger = requests, logger
6
+ end
7
+
8
+ def start
9
+ @timeout_thread = Thread.new do
10
+ while true do
11
+ clear_timeouts Time.now
12
+ sleep 0.05
13
+ end
14
+ end
15
+ end
16
+
17
+ def no_route(correlation_id)
18
+ if request = @requests[correlation_id]
19
+ @requests.delete correlation_id
20
+ request[:callback].call({error: 'Specified queue does not exist'}, nil)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def clear_timeouts(now)
27
+ @requests.each do |key, value|
28
+ timeout(key, value) if now > value[:timeout]
29
+ end
30
+ end
31
+
32
+ def timeout(correlation_id, request)
33
+ @requests.delete correlation_id
34
+
35
+ message = "Request #{correlation_id} timed out waiting response from #{request[:destination]} with timeout #{request[:timeout]}"
36
+ @logger.warn message
37
+ Freddy.notify 'RequestTimeout', message, request: correlation_id, destination: request[:destination], timeout: request[:timeout]
38
+ request[:callback].call({error: 'Timed out waiting for response'}, nil)
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,30 @@
1
+ class Freddy
2
+ class ResponderHandler
3
+
4
+ def initialize(consumer, channel)
5
+ @consumer = consumer
6
+ @channel = channel
7
+ end
8
+
9
+ def cancel
10
+ @consumer.cancel
11
+ end
12
+
13
+ def queue
14
+ @consumer.queue
15
+ end
16
+
17
+ def destroy_destination
18
+ @consumer.queue.delete
19
+ end
20
+
21
+ def join
22
+ @channel.work_pool.join
23
+ end
24
+
25
+ def shutdown
26
+ @channel.work_pool.shutdown
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,32 @@
1
+ require 'timeout'
2
+
3
+ class Freddy
4
+ class SyncResponseContainer
5
+ def call(response, delivery)
6
+ @response = response
7
+ @delivery = delivery
8
+ end
9
+
10
+ def wait_for_response(timeout)
11
+ Timeout::timeout(timeout) do
12
+ sleep 0.001 until filled?
13
+ end
14
+
15
+ if !@delivery || @delivery.properties[:type] == 'error'
16
+ raise ErrorResponse.new(@response)
17
+ else
18
+ @response
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def to_proc
25
+ Proc.new {|*args| self.call(*args)}
26
+ end
27
+
28
+ def filled?
29
+ !@response.nil?
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,23 @@
1
+ require 'spec_helper'
2
+
3
+ describe Freddy::Consumer do
4
+ let(:freddy) { Freddy.build(logger, config) }
5
+
6
+ let(:destination) { random_destination }
7
+ let(:payload) { {pay: 'load'} }
8
+
9
+ let(:consumer) { freddy.consumer }
10
+
11
+ it 'raises exception when no consumer is provided' do
12
+ expect { consumer.consume destination }.to raise_error described_class::EmptyConsumer
13
+ end
14
+
15
+ it "doesn't call passed block without any messages" do
16
+ consumer.consume destination do
17
+ @message_received = true
18
+ end
19
+ default_sleep
20
+
21
+ expect(@message_received).to be_falsy
22
+ end
23
+ end
@@ -0,0 +1,223 @@
1
+ require 'spec_helper'
2
+
3
+ describe Freddy do
4
+ let(:freddy) { described_class.build(logger, config) }
5
+
6
+ let(:destination) { random_destination }
7
+ let(:destination2) { random_destination }
8
+ let(:payload) { {pay: 'load'} }
9
+
10
+ def respond_to(&block)
11
+ freddy.respond_to(destination, &block)
12
+ end
13
+
14
+ context 'when making a send-and-forget request' do
15
+ context 'with timeout' do
16
+ it 'removes the message from the queue after the timeout' do
17
+ # Assume that there already is a queue. Otherwise will get an early
18
+ # return.
19
+ freddy.channel.queue(destination)
20
+
21
+ freddy.deliver(destination, {}, timeout: 0.1)
22
+ sleep 0.2
23
+
24
+ processed_after_timeout = false
25
+ respond_to { processed_after_timeout = true }
26
+ default_sleep
27
+
28
+ expect(processed_after_timeout).to be(false)
29
+ end
30
+ end
31
+
32
+ context 'without timeout' do
33
+ it 'keeps the message in the queue' do
34
+ # Assume that there already is a queue. Otherwise will get an early
35
+ # return.
36
+ freddy.channel.queue(destination)
37
+
38
+ freddy.deliver(destination, {})
39
+ default_sleep # to ensure everything is properly cleaned
40
+
41
+ processed_after_timeout = false
42
+ respond_to { processed_after_timeout = true }
43
+ default_sleep
44
+
45
+ expect(processed_after_timeout).to be(true)
46
+ end
47
+ end
48
+ end
49
+
50
+ context 'when making a synchronized request' do
51
+ it 'returns response as soon as possible' do
52
+ respond_to { |payload, msg_handler| msg_handler.success(res: 'yey') }
53
+ response = freddy.deliver_with_response(destination, {a: 'b'})
54
+
55
+ expect(response).to eq(res: 'yey')
56
+ end
57
+
58
+ it 'raises an error if the message was errored' do
59
+ respond_to { |payload, msg_handler| msg_handler.error(error: 'not today') }
60
+
61
+ expect {
62
+ freddy.deliver_with_response(destination, payload)
63
+ }.to raise_error(Freddy::ErrorResponse) {|error|
64
+ expect(error.response).to eq(error: 'not today')
65
+ }
66
+ end
67
+
68
+ it 'does not leak consumers' do
69
+ respond_to { |payload, msg_handler| msg_handler.success(res: 'yey') }
70
+
71
+ old_count = freddy.channel.consumers.keys.count
72
+
73
+ response1 = freddy.deliver_with_response(destination, {a: 'b'})
74
+ response2 = freddy.deliver_with_response(destination, {a: 'b'})
75
+
76
+ expect(response1).to eq(res: 'yey')
77
+ expect(response2).to eq(res: 'yey')
78
+
79
+ new_count = freddy.channel.consumers.keys.count
80
+ expect(new_count).to be(old_count + 1)
81
+ end
82
+
83
+ it 'responds to the correct requester' do
84
+ respond_to { |payload, msg_handler| msg_handler.success(res: 'yey') }
85
+
86
+ response = freddy.deliver_with_response(destination, payload)
87
+ expect(response).to eq(res: 'yey')
88
+
89
+ expect {
90
+ freddy.deliver_with_response(destination2, payload)
91
+ }.to raise_error(Freddy::ErrorResponse)
92
+ end
93
+
94
+ context 'when queue does not exist' do
95
+ it 'gives a no route error' do
96
+ begin
97
+ Timeout::timeout(0.5) do
98
+ expect {
99
+ freddy.deliver_with_response(destination, {a: 'b'}, timeout: 3)
100
+ }.to raise_error(Freddy::ErrorResponse) {|error|
101
+ expect(error.response).to eq(error: 'Specified queue does not exist')
102
+ }
103
+ end
104
+ rescue Timeout::Error
105
+ fail('Received a timeout error instead of the no route error')
106
+ end
107
+ end
108
+ end
109
+
110
+ context 'on timeout' do
111
+ it 'gives timeout error' do
112
+ respond_to { |payload, msg_handler| sleep 0.2 }
113
+
114
+ expect {
115
+ freddy.deliver_with_response(destination, {a: 'b'}, timeout: 0.1)
116
+ }.to raise_error(Freddy::ErrorResponse) {|error|
117
+ expect(error.response).to eq(error: 'Timed out waiting for response')
118
+ }
119
+ end
120
+
121
+ context 'with delete_on_timeout is set to true' do
122
+ it 'removes the message from the queue' do
123
+ # Assume that there already is a queue. Otherwise will get an early
124
+ # return.
125
+ freddy.channel.queue(destination)
126
+
127
+ expect {
128
+ freddy.deliver_with_response(destination, {}, timeout: 0.1)
129
+ }.to raise_error(Freddy::ErrorResponse)
130
+ default_sleep # to ensure everything is properly cleaned
131
+
132
+ processed_after_timeout = false
133
+ respond_to { processed_after_timeout = true }
134
+ default_sleep
135
+
136
+ expect(processed_after_timeout).to be(false)
137
+ end
138
+ end
139
+
140
+ context 'with delete_on_timeout is set to false' do
141
+ it 'removes the message from the queue' do
142
+ # Assume that there already is a queue. Otherwise will get an early
143
+ # return.
144
+ freddy.channel.queue(destination)
145
+
146
+ expect {
147
+ freddy.deliver_with_response(destination, {}, timeout: 0.1, delete_on_timeout: false)
148
+ }.to raise_error(Freddy::ErrorResponse)
149
+ default_sleep # to ensure everything is properly cleaned
150
+
151
+ processed_after_timeout = false
152
+ respond_to { processed_after_timeout = true }
153
+ default_sleep
154
+
155
+ expect(processed_after_timeout).to be(true)
156
+ end
157
+ end
158
+ end
159
+ end
160
+
161
+ describe 'when tapping' do
162
+ def tap(custom_destination = destination, &block)
163
+ freddy.tap_into(custom_destination, &block)
164
+ end
165
+
166
+ it 'receives messages' do
167
+ tap {|msg| @tapped_message = msg }
168
+ deliver
169
+
170
+ wait_for { @tapped_message }
171
+ expect(@tapped_message).to eq(payload)
172
+ end
173
+
174
+ it 'has the destination' do
175
+ tap "somebody.*.love" do |message, destination|
176
+ @destination = destination
177
+ end
178
+ deliver "somebody.to.love"
179
+
180
+ wait_for { @destination }
181
+ expect(@destination).to eq("somebody.to.love")
182
+ end
183
+
184
+ it "doesn't consume the message" do
185
+ tap { @tapped = true }
186
+ respond_to { @message_received = true }
187
+
188
+ deliver
189
+
190
+ wait_for { @tapped }
191
+ wait_for { @message_received }
192
+ expect(@tapped).to be(true)
193
+ expect(@message_received).to be(true)
194
+ end
195
+
196
+ it "allows * wildcard" do
197
+ tap("somebody.*.love") { @tapped = true }
198
+
199
+ deliver "somebody.to.love"
200
+
201
+ wait_for { @tapped }
202
+ expect(@tapped).to be(true)
203
+ end
204
+
205
+ it "* matches only one word" do
206
+ tap("somebody.*.love") { @tapped = true }
207
+
208
+ deliver "somebody.not.to.love"
209
+
210
+ default_sleep
211
+ expect(@tapped).to be_falsy
212
+ end
213
+
214
+ it "allows # wildcard" do
215
+ tap("i.#.free") { @tapped = true }
216
+
217
+ deliver "i.want.to.break.free"
218
+
219
+ wait_for { @tapped }
220
+ expect(@tapped).to be(true)
221
+ end
222
+ end
223
+ end