rack-rabbit 0.5.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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +3 -0
  3. data/EXAMPLES.md +212 -0
  4. data/Gemfile +14 -0
  5. data/Gemfile.lock +42 -0
  6. data/LICENSE +21 -0
  7. data/README.md +412 -0
  8. data/Rakefile +5 -0
  9. data/bin/rack-rabbit +96 -0
  10. data/bin/rr +99 -0
  11. data/lib/rack-rabbit.rb +63 -0
  12. data/lib/rack-rabbit/adapter.rb +85 -0
  13. data/lib/rack-rabbit/adapter/amqp.rb +114 -0
  14. data/lib/rack-rabbit/adapter/bunny.rb +87 -0
  15. data/lib/rack-rabbit/adapter/mock.rb +92 -0
  16. data/lib/rack-rabbit/client.rb +181 -0
  17. data/lib/rack-rabbit/config.rb +260 -0
  18. data/lib/rack-rabbit/handler.rb +44 -0
  19. data/lib/rack-rabbit/message.rb +95 -0
  20. data/lib/rack-rabbit/middleware/program_name.rb +34 -0
  21. data/lib/rack-rabbit/response.rb +43 -0
  22. data/lib/rack-rabbit/server.rb +263 -0
  23. data/lib/rack-rabbit/signals.rb +62 -0
  24. data/lib/rack-rabbit/subscriber.rb +77 -0
  25. data/lib/rack-rabbit/worker.rb +84 -0
  26. data/rack-rabbit.gemspec +26 -0
  27. data/test/apps/config.ru +7 -0
  28. data/test/apps/custom.conf +27 -0
  29. data/test/apps/custom.ru +7 -0
  30. data/test/apps/empty.conf +1 -0
  31. data/test/apps/error.ru +7 -0
  32. data/test/apps/mirror.ru +19 -0
  33. data/test/apps/sinatra.ru +37 -0
  34. data/test/apps/sleep.ru +21 -0
  35. data/test/test_case.rb +154 -0
  36. data/test/unit/middleware/test_program_name.rb +32 -0
  37. data/test/unit/test_client.rb +275 -0
  38. data/test/unit/test_config.rb +403 -0
  39. data/test/unit/test_handler.rb +92 -0
  40. data/test/unit/test_message.rb +213 -0
  41. data/test/unit/test_response.rb +59 -0
  42. data/test/unit/test_signals.rb +45 -0
  43. data/test/unit/test_subscriber.rb +140 -0
  44. metadata +91 -0
@@ -0,0 +1,62 @@
1
+ module RackRabbit
2
+ class Signals
3
+
4
+ # The RackRabbit server process has a single primary thread, but it doesn't
5
+ # actually need to do any work once it has spun up the worker processes. I
6
+ # need it to hibernate until one (or more) signal event's occurs.
7
+ #
8
+ # Originally I tried to use the standard Thread::Queue, it uses a Mutex to
9
+ # perform a blocking pop on the Queue. However Ruby (2.x) won't let the last
10
+ # active thread hibernate (it's overly aggressive at deadlock prevention)
11
+ #
12
+ # So, instead of using a Mutex based Queue, I can use a blocking select
13
+ # on an IO pipe, then when the signal handler pushes into the Queue
14
+ # it can also write to the pipe in order to "awaken" the primary thread.
15
+ #
16
+ # FYI: this is the same underlying idea that is used by the Unicorn master
17
+ # process, I've just encapsulated it in a Signals class
18
+ #
19
+
20
+ def initialize
21
+ @reader, @writer = IO.pipe
22
+ @queue = []
23
+ end
24
+
25
+ def close
26
+ @reader.close
27
+ @writer.close
28
+ @reader = @writer = nil
29
+ end
30
+
31
+ def closed?
32
+ @reader.nil?
33
+ end
34
+
35
+ def push(item)
36
+ raise RuntimeError, "closed" if closed?
37
+ @queue << item
38
+ awaken
39
+ end
40
+
41
+ def pop(options = {})
42
+ raise RuntimeError, "closed" if closed?
43
+ if @queue.empty? && (:timeout == hibernate(options[:timeout]))
44
+ :timeout
45
+ else
46
+ @queue.shift
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def awaken
53
+ @writer.write '.'
54
+ end
55
+
56
+ def hibernate(seconds = nil)
57
+ return :timeout unless IO.select([@reader], nil, nil, seconds)
58
+ @reader.readchar
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,77 @@
1
+ require 'rack-rabbit/adapter'
2
+ require 'rack-rabbit/handler'
3
+
4
+ module RackRabbit
5
+ class Subscriber
6
+
7
+ #--------------------------------------------------------------------------
8
+
9
+ attr_reader :config, # configuration options provided by the Server
10
+ :logger, # convenience for config.logger
11
+ :rabbit, # rabbitMQ adapter constructed by this class
12
+ :handler, # actually does the work of handling the RACK request/response
13
+ :lock # mutex to synchronize with Worker#shutdown for graceful QUIT handling
14
+
15
+ #--------------------------------------------------------------------------
16
+
17
+ def initialize(rabbit, handler, lock, config)
18
+ @rabbit = rabbit
19
+ @handler = handler
20
+ @lock = lock
21
+ @config = config
22
+ @logger = config.logger
23
+ end
24
+
25
+ #--------------------------------------------------------------------------
26
+
27
+ def subscribe
28
+ rabbit.startup
29
+ rabbit.connect
30
+ rabbit.subscribe(:queue => config.queue,
31
+ :exchange => config.exchange,
32
+ :exchange_type => config.exchange_type,
33
+ :routing_key => config.routing_key,
34
+ :ack => config.ack) do |message|
35
+ lock.synchronize do
36
+ start = Time.now
37
+ response = handle(message)
38
+ finish = Time.now
39
+ logger.info "\"#{message.method} #{message.path}\" [#{response.status}] - #{"%.4f" % (finish - start)}"
40
+ end
41
+ end
42
+ end
43
+
44
+ def unsubscribe
45
+ rabbit.disconnect
46
+ rabbit.shutdown
47
+ end
48
+
49
+ #==========================================================================
50
+ # PRIVATE IMPLEMENTATION
51
+ #==========================================================================
52
+
53
+ private
54
+
55
+ def handle(message)
56
+
57
+ response = handler.handle(message) # does all the Rack related work
58
+
59
+ if message.should_reply?
60
+ rabbit.publish(response.body, message.get_reply_properties(response, config))
61
+ end
62
+
63
+ if config.ack && !message.acknowledged? && !message.rejected?
64
+ if response.succeeded?
65
+ message.ack
66
+ else
67
+ message.reject
68
+ end
69
+ end
70
+
71
+ response
72
+ end
73
+
74
+ #--------------------------------------------------------------------------
75
+
76
+ end # class Subscriber
77
+ end # module RackRabbit
@@ -0,0 +1,84 @@
1
+ require 'rack-rabbit/signals'
2
+ require 'rack-rabbit/subscriber'
3
+ require 'rack-rabbit/handler'
4
+ require 'rack-rabbit/adapter'
5
+
6
+ module RackRabbit
7
+ class Worker
8
+
9
+ #--------------------------------------------------------------------------
10
+
11
+ attr_reader :config, # provided by the Server
12
+ :logger, # convenience for config.logger
13
+ :signals, # blocking Q for signal handling
14
+ :lock, # mutex to synchronise with Subscriber#subscribe for graceful QUIT handling
15
+ :rabbit, # interface to rabbit MQ
16
+ :subscriber, # actually does the work of subscribing to the rabbit queue
17
+ :handler # actually does the work of handling the rack request/response
18
+
19
+ #--------------------------------------------------------------------------
20
+
21
+ def initialize(config, app)
22
+ @config = config
23
+ @logger = config.logger
24
+ @signals = Signals.new
25
+ @lock = Mutex.new
26
+ @rabbit = Adapter.load(config.rabbit)
27
+ @handler = Handler.new(app, config)
28
+ @subscriber = Subscriber.new(rabbit, handler, lock, config)
29
+ end
30
+
31
+ #--------------------------------------------------------------------------
32
+
33
+ def run
34
+
35
+ logger.info "STARTED a new worker with PID #{Process.pid}"
36
+
37
+ trap_signals
38
+
39
+ subscriber.subscribe
40
+
41
+ while true
42
+ sig = signals.pop # BLOCKS until there is a signal
43
+ case sig
44
+ when :INT then shutdown(:INT)
45
+ when :QUIT then shutdown(:QUIT)
46
+ when :TERM then shutdown(:TERM)
47
+ else
48
+ raise RuntimeError, "unknown signal #{sig}"
49
+ end
50
+ end
51
+
52
+ ensure
53
+ subscriber.unsubscribe
54
+
55
+ end
56
+
57
+ #--------------------------------------------------------------------------
58
+
59
+ def shutdown(sig)
60
+ lock.lock if sig == :QUIT # graceful shutdown should wait for any pending message handler to finish
61
+ logger.info "#{RackRabbit.friendly_signal(sig)} worker #{Process.pid}"
62
+ exit
63
+ end
64
+
65
+ #--------------------------------------------------------------------------
66
+
67
+ def trap_signals # overwrite the handlers inherited from the server process
68
+
69
+ [:QUIT, :TERM, :INT].each do |sig|
70
+ trap(sig) do
71
+ signals.push(sig)
72
+ end
73
+ end
74
+
75
+ trap(:CHLD, :DEFAULT)
76
+ trap(:TTIN, nil)
77
+ trap(:TTOU, nil)
78
+
79
+ end
80
+
81
+ #--------------------------------------------------------------------------
82
+
83
+ end
84
+ end
@@ -0,0 +1,26 @@
1
+ # -*- encoding: utf-8 -*-
2
+ LIB = File.expand_path("lib", File.dirname(__FILE__))
3
+ $LOAD_PATH.unshift LIB unless $LOAD_PATH.include?(LIB)
4
+
5
+ require 'rack-rabbit'
6
+
7
+ Gem::Specification.new do |s|
8
+
9
+ s.name = "rack-rabbit"
10
+ s.version = RackRabbit::VERSION
11
+ s.platform = Gem::Platform::RUBY
12
+ s.authors = ["Jake Gordon"]
13
+ s.email = ["jake@codeincomplete.com"]
14
+ s.homepage = "https://github.com/jakesgordon/rack-rabbit"
15
+ s.summary = RackRabbit::SUMMARY
16
+
17
+ s.has_rdoc = false
18
+ s.extra_rdoc_files = ["README.md"]
19
+ s.rdoc_options = ["--charset=UTF-8"]
20
+ s.files = `git ls-files `.split("\n")
21
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
22
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
23
+ s.require_paths = ["lib"]
24
+ s.licenses = ["MIT"]
25
+
26
+ end
@@ -0,0 +1,7 @@
1
+ class DefaultApp
2
+ def self.call(env)
3
+ [ 200, {}, [ "Hello World" ] ]
4
+ end
5
+ end
6
+
7
+ run DefaultApp
@@ -0,0 +1,27 @@
1
+
2
+ rack_file "custom.ru"
3
+
4
+ rabbit :host => "10.10.10.10",
5
+ :port => "1234",
6
+ :adapter => "amqp"
7
+
8
+ queue "myqueue"
9
+ exchange "myexchange"
10
+ exchange_type "topic"
11
+ routing_key "myroute"
12
+ app_id "myapp"
13
+ workers 7
14
+ min_workers 3
15
+ max_workers 42
16
+ ack true
17
+ preload_app true
18
+ daemonize true
19
+ logfile "myapp.log"
20
+ pidfile "myapp.pid"
21
+ log_level :fatal
22
+
23
+ class ::MyLogger < Logger
24
+ end
25
+
26
+ logger MyLogger.new($stderr)
27
+
@@ -0,0 +1,7 @@
1
+ class CustomApp
2
+ def self.call(env)
3
+ [ 200, {}, [ "Custom App" ] ]
4
+ end
5
+ end
6
+
7
+ run CustomApp
@@ -0,0 +1 @@
1
+ # empty rack-rabbit configuration file
@@ -0,0 +1,7 @@
1
+ class ErrorApp
2
+ def self.call(env)
3
+ raise "wtf"
4
+ end
5
+ end
6
+
7
+ run ErrorApp
@@ -0,0 +1,19 @@
1
+ require 'json'
2
+
3
+ class MirrorApp
4
+ def self.call(env)
5
+
6
+ request = Rack::Request.new env
7
+ result = {
8
+ :method => request.request_method,
9
+ :path => request.path_info,
10
+ :params => request.params,
11
+ :body => request.body.read
12
+ }
13
+
14
+ [ 200, {}, [ result.to_json ] ]
15
+
16
+ end
17
+ end
18
+
19
+ run MirrorApp
@@ -0,0 +1,37 @@
1
+ require 'sinatra/base'
2
+
3
+ class MyApp < Sinatra::Base
4
+
5
+ set :logging, nil # skip sinatra logging middleware, use the env['rack.logger'] provided by the rack-rabbit server
6
+
7
+ get "/hello" do
8
+ "Hello World"
9
+ end
10
+
11
+ post "/submit" do
12
+ "Submitted #{request.body.read}"
13
+ end
14
+
15
+ get "/error" do
16
+ raise "uh oh"
17
+ end
18
+
19
+ get "/sleep/:seconds" do
20
+ slumber params[:seconds].to_i
21
+ end
22
+
23
+ post "/sleep/:seconds" do
24
+ slumber params[:seconds].to_i
25
+ end
26
+
27
+ def slumber(seconds)
28
+ seconds.times do |i|
29
+ logger.info "#{request.path_info} - #{i}"
30
+ sleep(1)
31
+ end
32
+ "Slept for #{seconds}"
33
+ end
34
+
35
+ end
36
+
37
+ run MyApp
@@ -0,0 +1,21 @@
1
+ class SleepApp
2
+
3
+ def self.call(env)
4
+
5
+ request = Rack::Request.new env
6
+ logger = request.logger
7
+ path = request.path_info.to_s
8
+
9
+ duration = path.split("/").last.to_i
10
+ duration.times do |n|
11
+ logger.info "#{path} - #{n}"
12
+ sleep(1)
13
+ end
14
+
15
+ [ 200, {}, [ "Slept for: #{duration}" ] ]
16
+
17
+ end
18
+
19
+ end
20
+
21
+ run SleepApp
@@ -0,0 +1,154 @@
1
+ require 'minitest/autorun'
2
+ require 'mocha/mini_test'
3
+ require 'rack'
4
+ require 'rack/builder'
5
+ require 'ostruct'
6
+ require 'timecop'
7
+ require 'pp'
8
+
9
+ require 'rack-rabbit' # top level module
10
+
11
+ require 'rack-rabbit/subscriber' # subscribes to rabbit queue/exchange and passes messages on to a handler
12
+ require 'rack-rabbit/handler' # converts rabbit messages to rack environments and calls a rack app to handle the request
13
+ require 'rack-rabbit/adapter' # abstract interface to rabbitMQ
14
+ require 'rack-rabbit/message' # a rabbitMQ message
15
+ require 'rack-rabbit/response' # a rack response
16
+
17
+ require 'rack-rabbit/client' # client code for making requests
18
+ require 'rack-rabbit/worker' # worker process
19
+ require 'rack-rabbit/server' # server process
20
+ require 'rack-rabbit/config' # server configuration
21
+ require 'rack-rabbit/signals' # process signal queue
22
+
23
+ module RackRabbit
24
+ class TestCase < Minitest::Unit::TestCase
25
+
26
+ #--------------------------------------------------------------------------
27
+
28
+ EMPTY_CONFIG = File.expand_path("apps/empty.conf", File.dirname(__FILE__))
29
+ CUSTOM_CONFIG = File.expand_path("apps/custom.conf", File.dirname(__FILE__))
30
+
31
+ #--------------------------------------------------------------------------
32
+
33
+ DEFAULT_RACK_APP = File.expand_path("apps/config.ru", File.dirname(__FILE__))
34
+ CUSTOM_RACK_APP = File.expand_path("apps/custom.ru", File.dirname(__FILE__))
35
+ ERROR_RACK_APP = File.expand_path("apps/error.ru", File.dirname(__FILE__))
36
+ MIRROR_RACK_APP = File.expand_path("apps/mirror.ru", File.dirname(__FILE__))
37
+
38
+ #--------------------------------------------------------------------------
39
+
40
+ APP_ID = "app.id"
41
+ DELIVERY_TAG = "delivery.tag"
42
+ REPLY_TO = "reply.queue"
43
+ CORRELATION_ID = "correlation.id"
44
+ QUEUE = "my.queue"
45
+ REPLY_QUEUE = "reply.queue"
46
+ EXCHANGE = "my.exchange"
47
+ ROUTE = "my.route"
48
+ BODY = "body"
49
+ PATH = "/foo/bar"
50
+ QUERY = "a=b&c=d"
51
+ URI = "#{PATH}?#{QUERY}"
52
+ PRIORITY = 7
53
+
54
+ module CONTENT
55
+ ASCII = "iso-8859-1"
56
+ UTF8 = "utf-8"
57
+ PLAIN_TEXT = "text/plain"
58
+ PLAIN_TEXT_UTF8 = "text/plain; charset=\"utf-8\""
59
+ FORM_URLENCODED = "application/x-www-form-urlencoded"
60
+ FORM_URLENCODED_UTF8 = "application/x-www-form-urlencoded; charset=\"utf-8\""
61
+ JSON = "application/json"
62
+ JSON_UTF8 = "application/json; charset=\"utf-8\""
63
+ JSON_ASCII = "application/json; charset=\"iso-8859-1\""
64
+ end
65
+
66
+ #--------------------------------------------------------------------------
67
+
68
+ NullLogger = Rack::NullLogger.new($stdout)
69
+
70
+ #--------------------------------------------------------------------------
71
+
72
+ def default_config
73
+ build_config( :rabbit => nil, :logger => nil ) # special case for select tests that want TRUE defaults (not the :mock adapter or NullLogger needed in 80% of other tests)
74
+ end
75
+
76
+ def build_config(options = {})
77
+ Config.new({
78
+ :validate => false, # skip validation for most tests
79
+ :rack_file => DEFAULT_RACK_APP, # required - so default to sample app
80
+ :rabbit => { :adapter => :mock }, # use RackRabbit::Adapter::Mock to mock out rabbit MQ
81
+ :logger => NullLogger # suppress logging during tests
82
+ }.merge(options))
83
+ end
84
+
85
+ def build_client(options = {})
86
+ Client.new({ :adapter => :mock }.merge(options))
87
+ end
88
+
89
+ def build_message(options = {})
90
+ options[:headers] ||= {}
91
+ options[:headers][RackRabbit::HEADER::METHOD] ||= options.delete(:method) # convenience to make calling code a little more compact
92
+ options[:headers][RackRabbit::HEADER::PATH] ||= options.delete(:path) # (ditto)
93
+ options[:headers][RackRabbit::HEADER::STATUS] ||= options.delete(:status) # (ditto)
94
+ Message.new(options[:delivery_tag], OpenStruct.new(options), options[:body], options[:rabbit] || build_rabbit)
95
+ end
96
+
97
+ def build_response(status, body, headers = {})
98
+ headers ||= {}
99
+ headers[RackRabbit::HEADER::CONTENT_TYPE] ||= headers.delete(:content_type) # convenience to make calling code a little more compact
100
+ headers[RackRabbit::HEADER::CONTENT_ENCODING] ||= headers.delete(:content_encoding) # (ditto)
101
+ Response.new(status, headers, body)
102
+ end
103
+
104
+ def build_app(rack_file)
105
+ Rack::Builder.parse_file(rack_file)[0]
106
+ end
107
+
108
+ def build_handler(options = {})
109
+ config = options[:config] || build_config(options)
110
+ app = options[:app] || build_app(config.rack_file)
111
+ Handler.new(app, config)
112
+ end
113
+
114
+ def build_subscriber(options = {})
115
+ rabbit = options[:rabbit] || build_rabbit(options)
116
+ config = options[:config] || build_config(options)
117
+ handler = options[:handler] || build_handler(options.merge(:config => config))
118
+ Subscriber.new(rabbit, handler, Mutex.new, handler.config)
119
+ end
120
+
121
+ def build_rabbit(options = {})
122
+ Adapter.load({ :adapter => :mock }.merge(options))
123
+ end
124
+
125
+ #--------------------------------------------------------------------------
126
+
127
+ def assert_raises_argument_error(message = nil, &block)
128
+ e = assert_raises(ArgumentError, &block)
129
+ assert_match(/#{message}/, e.message) unless message.nil?
130
+ end
131
+
132
+ #--------------------------------------------------------------------------
133
+
134
+ def measure
135
+ start = Time.now
136
+ yield
137
+ finish = Time.now
138
+ finish - start
139
+ end
140
+
141
+ #--------------------------------------------------------------------------
142
+
143
+ def with_program_name(name)
144
+ original = $PROGRAM_NAME
145
+ $PROGRAM_NAME = name
146
+ yield
147
+ ensure
148
+ $PROGRAM_NAME = original
149
+ end
150
+
151
+ #--------------------------------------------------------------------------
152
+
153
+ end
154
+ end