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.
@@ -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