freddy-jruby 0.4.3

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