freddy-jruby 0.4.3
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 +7 -0
- data/.gitignore +5 -0
- data/.npmignore +8 -0
- data/.rspec +2 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/.travis.yml +10 -0
- data/Gemfile +8 -0
- data/LICENCE.txt +22 -0
- data/README.md +170 -0
- data/Rakefile +7 -0
- data/freddy.gemspec +35 -0
- data/lib/freddy/adaptive_queue.rb +34 -0
- data/lib/freddy/consumer.rb +77 -0
- data/lib/freddy/delivery.rb +14 -0
- data/lib/freddy/message_handler.rb +19 -0
- data/lib/freddy/message_handlers.rb +68 -0
- data/lib/freddy/producer.rb +42 -0
- data/lib/freddy/request.rb +124 -0
- data/lib/freddy/request_manager.rb +45 -0
- data/lib/freddy/responder_handler.rb +21 -0
- data/lib/freddy/sync_response_container.rb +34 -0
- data/lib/freddy.rb +124 -0
- data/spec/freddy/consumer_spec.rb +25 -0
- data/spec/freddy/error_response_spec.rb +59 -0
- data/spec/freddy/freddy_spec.rb +225 -0
- data/spec/freddy/message_handler_spec.rb +27 -0
- data/spec/freddy/request_spec.rb +41 -0
- data/spec/freddy/responder_handler_spec.rb +22 -0
- data/spec/integration/concurrency_spec.rb +65 -0
- data/spec/integration/logging_spec.rb +35 -0
- data/spec/spec_helper.rb +40 -0
- metadata +168 -0
@@ -0,0 +1,68 @@
|
|
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, destination, logger)
|
9
|
+
@destination = destination
|
10
|
+
@producer = producer
|
11
|
+
@logger = logger
|
12
|
+
end
|
13
|
+
|
14
|
+
def handle_message(payload, msg_handler, &block)
|
15
|
+
block.call payload, msg_handler
|
16
|
+
rescue Exception => e
|
17
|
+
@logger.error "Exception occured while processing message from #{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, destination, logger)
|
32
|
+
@producer = producer
|
33
|
+
@logger = logger
|
34
|
+
@destination = destination
|
35
|
+
end
|
36
|
+
|
37
|
+
def handle_message(payload, msg_handler, &block)
|
38
|
+
@correlation_id = msg_handler.correlation_id
|
39
|
+
|
40
|
+
if !@correlation_id
|
41
|
+
@logger.error "Received request without correlation_id"
|
42
|
+
Freddy.notify_exception(e)
|
43
|
+
else
|
44
|
+
block.call payload, msg_handler
|
45
|
+
end
|
46
|
+
rescue Exception => e
|
47
|
+
@logger.error "Exception occured while handling the request with correlation_id #{@correlation_id}: #{Freddy.format_exception(e)}"
|
48
|
+
Freddy.notify_exception(e, correlation_id: @correlation_id, destination: @destination)
|
49
|
+
end
|
50
|
+
|
51
|
+
def success(reply_to, response)
|
52
|
+
send_response(reply_to, response, type: 'success')
|
53
|
+
end
|
54
|
+
|
55
|
+
def error(reply_to, response)
|
56
|
+
send_response(reply_to, response, type: 'error')
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def send_response(reply_to, response, opts = {})
|
62
|
+
@producer.produce reply_to.force_encoding('utf-8'), response, {
|
63
|
+
correlation_id: @correlation_id
|
64
|
+
}.merge(opts)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require_relative 'request'
|
2
|
+
require 'json'
|
3
|
+
|
4
|
+
class Freddy
|
5
|
+
class Producer
|
6
|
+
OnReturnNotImplemented = Class.new(NoMethodError)
|
7
|
+
|
8
|
+
CONTENT_TYPE = 'application/json'.freeze
|
9
|
+
|
10
|
+
def initialize(channel, logger)
|
11
|
+
@channel, @logger = channel, logger
|
12
|
+
@exchange = @channel.default_exchange
|
13
|
+
@topic_exchange = @channel.topic Freddy::FREDDY_TOPIC_EXCHANGE_NAME
|
14
|
+
end
|
15
|
+
|
16
|
+
def produce(destination, payload, properties={})
|
17
|
+
@logger.debug "Producing message #{payload.inspect} to #{destination}"
|
18
|
+
|
19
|
+
properties = properties.merge(routing_key: destination, content_type: CONTENT_TYPE)
|
20
|
+
json_payload = payload.to_json
|
21
|
+
|
22
|
+
@topic_exchange.publish json_payload, properties.dup
|
23
|
+
@exchange.publish json_payload, properties.dup
|
24
|
+
end
|
25
|
+
|
26
|
+
def on_return(&block)
|
27
|
+
if @exchange.respond_to? :on_return # Bunny
|
28
|
+
@exchange.on_return do |return_info, properties, content|
|
29
|
+
block.call(return_info[:reply_code], properties[:correlation_id])
|
30
|
+
end
|
31
|
+
elsif @channel.respond_to? :on_return # Hare
|
32
|
+
@channel.on_return do |reply_code, _, exchange_name, _, properties|
|
33
|
+
if exchange_name != Freddy::FREDDY_TOPIC_EXCHANGE_NAME
|
34
|
+
block.call(reply_code, properties.correlation_id)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
else
|
38
|
+
raise OnReturnNotImplemented.new "AMQP implementation doesn't implement on_return"
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,124 @@
|
|
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 'thread'
|
7
|
+
require 'securerandom'
|
8
|
+
require 'hamster/mutable_hash'
|
9
|
+
|
10
|
+
class Freddy
|
11
|
+
class Request
|
12
|
+
NO_ROUTE = 312
|
13
|
+
|
14
|
+
class EmptyRequest < Exception
|
15
|
+
end
|
16
|
+
|
17
|
+
class EmptyResponder < Exception
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(channel, logger, producer, consumer)
|
21
|
+
@channel, @logger = channel, logger
|
22
|
+
@producer, @consumer = producer, consumer
|
23
|
+
@request_map = Hamster.mutable_hash
|
24
|
+
@request_manager = RequestManager.new @request_map, @logger
|
25
|
+
|
26
|
+
@producer.on_return do |reply_code, correlation_id|
|
27
|
+
if reply_code == NO_ROUTE
|
28
|
+
@request_manager.no_route(correlation_id)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
@listening_for_responses_lock = Mutex.new
|
33
|
+
@response_queue_lock = Mutex.new
|
34
|
+
end
|
35
|
+
|
36
|
+
def sync_request(destination, payload, opts)
|
37
|
+
timeout_seconds = opts.fetch(:timeout)
|
38
|
+
container = SyncResponseContainer.new
|
39
|
+
async_request destination, payload, opts, &container
|
40
|
+
container.wait_for_response(timeout_seconds + 0.1)
|
41
|
+
end
|
42
|
+
|
43
|
+
def async_request(destination, payload, options, &block)
|
44
|
+
timeout = options.fetch(:timeout)
|
45
|
+
delete_on_timeout = options.fetch(:delete_on_timeout)
|
46
|
+
options.delete(:timeout)
|
47
|
+
options.delete(:delete_on_timeout)
|
48
|
+
|
49
|
+
ensure_listening_to_responses
|
50
|
+
|
51
|
+
correlation_id = SecureRandom.uuid
|
52
|
+
@request_map.store(correlation_id, callback: block, destination: destination, timeout: Time.now + timeout)
|
53
|
+
|
54
|
+
@logger.debug "Publishing request to #{destination}, waiting for response on #{@response_queue.name} with correlation_id #{correlation_id}"
|
55
|
+
|
56
|
+
if delete_on_timeout
|
57
|
+
options[:expiration] = (timeout * 1000).to_i
|
58
|
+
end
|
59
|
+
|
60
|
+
@producer.produce destination, payload, options.merge(
|
61
|
+
correlation_id: correlation_id, reply_to: @response_queue.name,
|
62
|
+
mandatory: true, type: 'request'
|
63
|
+
)
|
64
|
+
end
|
65
|
+
|
66
|
+
def respond_to(destination, &block)
|
67
|
+
raise EmptyResponder unless block
|
68
|
+
|
69
|
+
ensure_response_queue_exists
|
70
|
+
@logger.info "Listening for requests on #{destination}"
|
71
|
+
responder_handler = @consumer.consume destination do |payload, delivery|
|
72
|
+
handler = MessageHandlers.for_type(delivery.metadata.type).new(@producer, destination, @logger)
|
73
|
+
|
74
|
+
msg_handler = MessageHandler.new(handler, delivery)
|
75
|
+
handler.handle_message payload, msg_handler, &block
|
76
|
+
end
|
77
|
+
responder_handler
|
78
|
+
end
|
79
|
+
|
80
|
+
private
|
81
|
+
|
82
|
+
def create_response_queue
|
83
|
+
AdaptiveQueue.new @channel.queue("", exclusive: true)
|
84
|
+
end
|
85
|
+
|
86
|
+
def handle_response(payload, delivery)
|
87
|
+
correlation_id = delivery.metadata.correlation_id
|
88
|
+
request = @request_map[correlation_id]
|
89
|
+
if request
|
90
|
+
@logger.debug "Got response for request to #{request[:destination]} with correlation_id #{correlation_id}"
|
91
|
+
@request_map.delete correlation_id
|
92
|
+
request[:callback].call payload, delivery
|
93
|
+
else
|
94
|
+
@logger.warn "Got rpc response for correlation_id #{correlation_id} but there is no requester"
|
95
|
+
Freddy.notify 'NoRequesterForResponse', "Got rpc response but there is no requester", correlation_id: correlation_id
|
96
|
+
end
|
97
|
+
rescue Exception => e
|
98
|
+
destination_report = request ? "to #{request[:destination]}" : ''
|
99
|
+
@logger.error "Exception occured while handling the response of request made #{destination_report} with correlation_id #{correlation_id}: #{Freddy.format_exception e}"
|
100
|
+
Freddy.notify_exception(e, destination: request[:destination], correlation_id: correlation_id)
|
101
|
+
end
|
102
|
+
|
103
|
+
def ensure_response_queue_exists
|
104
|
+
@response_queue_lock.synchronize do
|
105
|
+
@response_queue ||= create_response_queue
|
106
|
+
end
|
107
|
+
end
|
108
|
+
|
109
|
+
def ensure_listening_to_responses
|
110
|
+
@listening_for_responses_lock.synchronize do
|
111
|
+
if @listening_for_responses
|
112
|
+
true
|
113
|
+
else
|
114
|
+
ensure_response_queue_exists
|
115
|
+
@request_manager.start
|
116
|
+
@consumer.dedicated_consume @response_queue do |payload, delivery|
|
117
|
+
handle_response payload, delivery
|
118
|
+
end
|
119
|
+
@listening_for_responses = true
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,45 @@
|
|
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
|
+
@logger.warn "Request timed out waiting response from #{request[:destination]}, correlation id #{correlation_id}"
|
36
|
+
Freddy.notify 'RequestTimeout', "Request timed out waiting for response from #{request[:destination]}", {
|
37
|
+
correlation_id: correlation_id,
|
38
|
+
destination: request[:destination],
|
39
|
+
timeout: request[:timeout]
|
40
|
+
}
|
41
|
+
|
42
|
+
request[:callback].call({error: 'RequestTimeout', message: 'Timed out waiting for response'}, nil)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,21 @@
|
|
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
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,34 @@
|
|
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 @response[:error] == 'RequestTimeout'
|
16
|
+
raise TimeoutError.new(@response)
|
17
|
+
elsif !@delivery || @delivery.metadata.type == 'error'
|
18
|
+
raise InvalidRequestError.new(@response)
|
19
|
+
else
|
20
|
+
@response
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
def to_proc
|
27
|
+
Proc.new {|*args| self.call(*args)}
|
28
|
+
end
|
29
|
+
|
30
|
+
def filled?
|
31
|
+
!@response.nil?
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
data/lib/freddy.rb
ADDED
@@ -0,0 +1,124 @@
|
|
1
|
+
if RUBY_PLATFORM == 'java'
|
2
|
+
require 'march_hare'
|
3
|
+
else
|
4
|
+
require 'bunny'
|
5
|
+
end
|
6
|
+
|
7
|
+
require 'json'
|
8
|
+
require 'symbolizer'
|
9
|
+
require 'thread/pool'
|
10
|
+
|
11
|
+
require_relative 'freddy/adaptive_queue'
|
12
|
+
require_relative 'freddy/consumer'
|
13
|
+
require_relative 'freddy/producer'
|
14
|
+
require_relative 'freddy/request'
|
15
|
+
|
16
|
+
class Freddy
|
17
|
+
class ErrorResponse < StandardError
|
18
|
+
DEFAULT_ERROR_MESSAGE = 'Use #response to get the error response'
|
19
|
+
|
20
|
+
attr_reader :response
|
21
|
+
|
22
|
+
def initialize(response)
|
23
|
+
@response = response
|
24
|
+
super(format_message(response) || DEFAULT_ERROR_MESSAGE)
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def format_message(response)
|
30
|
+
return unless response.is_a?(Hash)
|
31
|
+
|
32
|
+
message = [response[:error], response[:message]].compact.join(': ')
|
33
|
+
message.empty? ? nil : message
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
class InvalidRequestError < ErrorResponse
|
38
|
+
end
|
39
|
+
|
40
|
+
class TimeoutError < ErrorResponse
|
41
|
+
end
|
42
|
+
|
43
|
+
FREDDY_TOPIC_EXCHANGE_NAME = 'freddy-topic'.freeze
|
44
|
+
|
45
|
+
def self.format_backtrace(backtrace)
|
46
|
+
backtrace.map{ |x|
|
47
|
+
x.match(/^(.+?):(\d+)(|:in `(.+)')$/);
|
48
|
+
[$1,$2,$4]
|
49
|
+
}.join "\n"
|
50
|
+
end
|
51
|
+
|
52
|
+
def self.format_exception(exception)
|
53
|
+
"#{exception.exception}\n#{format_backtrace(exception.backtrace)}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def self.notify(name, message, parameters={})
|
57
|
+
if defined? Airbrake
|
58
|
+
Airbrake.notify_or_ignore({
|
59
|
+
error_class: name,
|
60
|
+
error_message: message,
|
61
|
+
cgi_data: ENV.to_hash,
|
62
|
+
parameters: parameters
|
63
|
+
})
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def self.notify_exception(exception, parameters={})
|
68
|
+
if defined? Airbrake
|
69
|
+
Airbrake.notify_or_ignore(exception, cgi_data: ENV.to_hash, parameters: parameters)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def self.build(logger = Logger.new(STDOUT), config)
|
74
|
+
if RUBY_PLATFORM == 'java'
|
75
|
+
connection = MarchHare.connect(config)
|
76
|
+
else
|
77
|
+
connection = Bunny.new(config)
|
78
|
+
connection.start
|
79
|
+
connection
|
80
|
+
end
|
81
|
+
|
82
|
+
new(connection, logger, config.fetch(:max_concurrency, 4))
|
83
|
+
end
|
84
|
+
|
85
|
+
attr_reader :channel, :consumer, :producer, :request
|
86
|
+
|
87
|
+
def initialize(connection, logger, max_concurrency)
|
88
|
+
@connection = connection
|
89
|
+
@channel = connection.create_channel
|
90
|
+
@consume_thread_pool = Thread.pool(max_concurrency)
|
91
|
+
@consumer = Consumer.new channel, logger, @consume_thread_pool
|
92
|
+
@producer = Producer.new channel, logger
|
93
|
+
@request = Request.new channel, logger, @producer, @consumer
|
94
|
+
end
|
95
|
+
|
96
|
+
def respond_to(destination, &callback)
|
97
|
+
@request.respond_to destination, &callback
|
98
|
+
end
|
99
|
+
|
100
|
+
def tap_into(pattern, &callback)
|
101
|
+
@consumer.tap_into pattern, &callback
|
102
|
+
end
|
103
|
+
|
104
|
+
def deliver(destination, payload, options = {})
|
105
|
+
timeout = options.fetch(:timeout, 0)
|
106
|
+
opts = {}
|
107
|
+
opts[:expiration] = (timeout * 1000).to_i if timeout > 0
|
108
|
+
|
109
|
+
@producer.produce destination, payload, opts
|
110
|
+
end
|
111
|
+
|
112
|
+
def deliver_with_response(destination, payload, options = {})
|
113
|
+
timeout = options.fetch(:timeout, 3)
|
114
|
+
delete_on_timeout = options.fetch(:delete_on_timeout, true)
|
115
|
+
|
116
|
+
@request.sync_request destination, payload, {
|
117
|
+
timeout: timeout, delete_on_timeout: delete_on_timeout
|
118
|
+
}
|
119
|
+
end
|
120
|
+
|
121
|
+
def close
|
122
|
+
@connection.close
|
123
|
+
end
|
124
|
+
end
|