freddy 0.3.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,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