gongren 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 François Beausoleil
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,19 @@
1
+ = gongren
2
+
3
+ Gongren distributes jobs to workers, with support for failed worker daemons and load balancing.
4
+
5
+ Gongren is Chinese worker: 工人. See http://www.ehow.com/video_4403851_say-worker-chinese.html for pronunciation.
6
+
7
+ == Note on Patches/Pull Requests
8
+
9
+ * Fork the project.
10
+ * Make your feature addition or bug fix.
11
+ * Add tests for it. This is important so I don't break it in a
12
+ future version unintentionally.
13
+ * Commit, do not mess with rakefile, version, or history.
14
+ (if you want to have your own version, that is fine but bump version in a commit by itself I can ignore when I pull)
15
+ * Send me a pull request. Bonus points for topic branches.
16
+
17
+ == Copyright
18
+
19
+ Copyright (c) 2010 François Beausoleil. See LICENSE for details.
@@ -0,0 +1,82 @@
1
+ require 'rubygems'
2
+ require 'rake'
3
+
4
+ begin
5
+ require 'jeweler'
6
+ Jeweler::Tasks.new do |gem|
7
+ gem.name = "gongren"
8
+ gem.summary = %Q{Gongren distributes jobs to workers, with support for failed worker daemons and load balancing.}
9
+ gem.description = %Q{A gem that's currently tied to Rails to distribute jobs.}
10
+ gem.email = "francois@teksol.info"
11
+ gem.homepage = "http://github.com/francois/gongren"
12
+ gem.authors = ["François Beausoleil"]
13
+ gem.require_paths << "vendor/qusion/lib"
14
+ gem.files = FileList["lib/**/*", "vendor/**/*", "LICENSE", "README.rdoc", "Rakefile"]
15
+ gem.add_development_dependency "thoughtbot-shoulda", ">= 0"
16
+ gem.add_development_dependency "yard", ">= 0"
17
+ # gem is a Gem::Specification... see http://www.rubygems.org/read/chapter/20 for additional settings
18
+
19
+ gem.add_dependency "amqp", "~> 0.6.6"
20
+ end
21
+ Jeweler::GemcutterTasks.new
22
+ rescue LoadError
23
+ puts "Jeweler (or a dependency) not available. Install it with: gem install jeweler"
24
+ end
25
+
26
+ require 'rake/testtask'
27
+ Rake::TestTask.new(:test) do |test|
28
+ test.libs << 'lib' << 'test'
29
+ test.pattern = 'test/**/test_*.rb'
30
+ test.verbose = true
31
+ end
32
+
33
+ begin
34
+ require 'rcov/rcovtask'
35
+ Rcov::RcovTask.new do |test|
36
+ test.libs << 'test'
37
+ test.pattern = 'test/**/test_*.rb'
38
+ test.verbose = true
39
+ end
40
+ rescue LoadError
41
+ task :rcov do
42
+ abort "RCov is not available. In order to run rcov, you must: sudo gem install spicycode-rcov"
43
+ end
44
+ end
45
+
46
+ task :test => :check_dependencies
47
+
48
+ begin
49
+ require 'reek/adapters/rake_task'
50
+ Reek::RakeTask.new do |t|
51
+ t.fail_on_error = true
52
+ t.verbose = false
53
+ t.source_files = 'lib/**/*.rb'
54
+ end
55
+ rescue LoadError
56
+ task :reek do
57
+ abort "Reek is not available. In order to run reek, you must: sudo gem install reek"
58
+ end
59
+ end
60
+
61
+ begin
62
+ require 'roodi'
63
+ require 'roodi_task'
64
+ RoodiTask.new do |t|
65
+ t.verbose = false
66
+ end
67
+ rescue LoadError
68
+ task :roodi do
69
+ abort "Roodi is not available. In order to run roodi, you must: sudo gem install roodi"
70
+ end
71
+ end
72
+
73
+ task :default => :test
74
+
75
+ begin
76
+ require 'yard'
77
+ YARD::Rake::YardocTask.new
78
+ rescue LoadError
79
+ task :yardoc do
80
+ abort "YARD is not available. In order to run yardoc, you must: sudo gem install yard"
81
+ end
82
+ end
@@ -0,0 +1,4 @@
1
+ # Nothing interesting here: see {Gongren::Server} or {Gongren::Worker}.
2
+ module Gongren
3
+ VERSION = "0.0.1"
4
+ end
@@ -0,0 +1,58 @@
1
+ require "logger"
2
+ require "qusion"
3
+
4
+ module Gongren
5
+ # This version is intimately tied to Rails.
6
+ class Server
7
+ def initialize(options={})
8
+ @options = options.inject(Hash.new) {|memo, (k,v)| memo[k.to_sym] = v; memo} # #symbolize_keys
9
+ @logger = options[:logger] || Logger.new(options[:log] || STDERR)
10
+ end
11
+
12
+ # Submits a unit of work to the pool of workers.
13
+ def self.submit(name, unit)
14
+ data = Marshal.dump(unit)
15
+ topic.publish(data, :persistent => true, :key => "unit.#{name}")
16
+ end
17
+
18
+ # A quick way to instantiate a server with some options.
19
+ def self.start(options={})
20
+ new(options).start
21
+ end
22
+
23
+ # Starts the reactor / event loop.
24
+ def start
25
+ logger.info { "Gongren::Server #{Process.pid} starting" }
26
+ Qusion.start(@options)
27
+ self.class.control_topic # Instantiates the control topic, for later use
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :options, :logger
33
+
34
+ def self.exchange_name
35
+ "gongren.work"
36
+ end
37
+
38
+ def self.exchange_options
39
+ {:durable => true}
40
+ end
41
+
42
+ def self.topic
43
+ Qusion.channel.topic(exchange_name, exchange_options)
44
+ end
45
+
46
+ def self.control_exchange_name
47
+ "gongren.worker.control"
48
+ end
49
+
50
+ def self.control_exchange_options
51
+ {}
52
+ end
53
+
54
+ def self.control_topic
55
+ Qusion.channel.topic(control_exchange_name, control_exchange_options)
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,166 @@
1
+ require "json"
2
+ require "mq"
3
+ require "logger"
4
+
5
+ module Gongren
6
+ # A worker does work. Units of work are received and processed locally. All units of work will be
7
+ # acknowledged back to the server, ensuring units of work are executed exactly once.
8
+ #
9
+ # Units of work are received as Hashes from the {Gongren::Server}. The Hash that is passed to the
10
+ # #run block will be dynamically injected with {Gongren::Worker::Unit}, which includes the {Gongren::Worker::Unit#ack}
11
+ # method. If the block returns and the message hasn't been acknowledged, it will be for you.
12
+ #
13
+ # == Notes on use
14
+ #
15
+ # If you do any database work, it is important to wrap your work in a transaction, because if your
16
+ # worker dies, the same work unit will be resubmitted.
17
+ #
18
+ # @example
19
+ #
20
+ # # In a Rails context, this would live in script/gongren-worker:
21
+ # require File.dirname(__FILE__) + "/../config/environment"
22
+ # require "gongren/worker"
23
+ #
24
+ # Gengren::Worker.run do |unit|
25
+ # ActiveRecord::Base.transaction do
26
+ # klass_name = unit[:class_name]
27
+ # klass = klass_name.constantize
28
+ # instance = klass.find(unit[:id])
29
+ # results = instance.send(unit[:selector], *unit[:args])
30
+ # end
31
+ #
32
+ # # We don't have two phase commit yet, this acknowledging outside the
33
+ # # transaction might execute a message twice.
34
+ # unit.ack
35
+ # end
36
+ class Worker
37
+ def initialize(options={})
38
+ @options = options.inject(Hash.new) {|memo, (k,v)| memo[k.to_sym] = v; memo} # #symbolize_keys
39
+ @logger = options[:logger] || Logger.new(options[:log] || STDERR)
40
+ end
41
+
42
+ def run
43
+ raise ArgumentError, "#run must be called with a block" unless block_given?
44
+
45
+ logger.info { "Gongren::Worker #{worker_id} ready to work" }
46
+
47
+ EM.run do
48
+ MQ.queue(control_queue_name, control_queue_options).bind(control_exchange_name, control_exchange_options) do |header, data|
49
+ message = Marshal.load(data)
50
+ logger.info { message.inspect }
51
+
52
+ if message[:selector].to_s.strip.empty? then
53
+ logger.error { "Received control request without :selector key: ignoring" }
54
+ else
55
+ begin
56
+ send(message[:selector], message)
57
+ rescue Exception => e
58
+ log_failure(header, message, e)
59
+ end
60
+ end
61
+ end
62
+
63
+ MQ.queue(queue_name, queue_options).bind(exchange_name, exchange_options).subscribe do |header, data|
64
+ message = Marshal.load(data)
65
+ class << message; include Unit; end # Dynamically add our #ack method
66
+ message.gongren_header = header
67
+
68
+ logger.info { message.inspect }
69
+
70
+ begin
71
+ yield message
72
+
73
+ # Automatically ack messages, but do it only once
74
+ logger.debug { "Block ack'd? #{message.acked?}" }
75
+ unless message.acked?
76
+ logger.debug { "Ack'ing for the block" }
77
+ message.ack
78
+ end
79
+ rescue Exception => e
80
+ log_failure(header, message, e)
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ # A quick way to run a worker. Creates an instance with the options and runs the block
87
+ # whenever a message is received, passing the exact object that was sent from the server.
88
+ def self.run(options={}, &block)
89
+ new(options).run(&block)
90
+ end
91
+
92
+ module Unit
93
+ attr_writer :gongren_header
94
+
95
+ def ack
96
+ @gongren_header.ack
97
+ @acked = true
98
+ end
99
+
100
+ def acked?
101
+ !!@acked
102
+ end
103
+ end
104
+
105
+ private
106
+
107
+ def log_failure(header, message, exception)
108
+ logger.error do
109
+ <<-EOF.gsub(/^\s{10}/, "")
110
+ ============
111
+ #{header.inspect}
112
+ #{message.inspect}
113
+ message.acked? #{message.acked?}
114
+ ============
115
+ #{e.class_name}: #{e.message}
116
+ #{e.backtrace.join("\n")}.
117
+ EOF
118
+ end
119
+ end
120
+
121
+ def ping(message)
122
+ return logger.error { "Received #ping message without corresponding :exchange key: ignoring" } if message[:exchange].to_s.strip.empty?
123
+ MQ.direct(data[:exchange]).publish(worker_id)
124
+ end
125
+
126
+ attr_reader :logger, :options
127
+
128
+ def queue_name
129
+ "gongren.worker"
130
+ end
131
+
132
+ def queue_options
133
+ # We want a durable queue: one that survives server restarts, and that will hold messages until
134
+ # a worker is available to grab them, even if all worker process are down.
135
+ {:durable => true}
136
+ end
137
+
138
+ def exchange_name
139
+ "gongren.work"
140
+ end
141
+
142
+ def exchange_options
143
+ {:key => "unit.#"}
144
+ end
145
+
146
+ def control_queue_name
147
+ "gongren.worker.control.#{worker_id}"
148
+ end
149
+
150
+ def control_queue_options
151
+ {}
152
+ end
153
+
154
+ def control_exchange_name
155
+ "gongren.worker.control"
156
+ end
157
+
158
+ def control_exchange_options
159
+ {}
160
+ end
161
+
162
+ def worker_id
163
+ "#{Process.pid}.#{Thread.current.object_id}"
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,10 @@
1
+ require 'rubygems'
2
+ require 'test/unit'
3
+ require 'shoulda'
4
+
5
+ $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
6
+ $LOAD_PATH.unshift(File.dirname(__FILE__))
7
+ require 'gongren'
8
+
9
+ class Test::Unit::TestCase
10
+ end
@@ -0,0 +1,7 @@
1
+ require 'helper'
2
+
3
+ class TestGongren < Test::Unit::TestCase
4
+ should "probably rename this file and start testing for real" do
5
+ flunk "hey buddy, you should probably rename this file and start testing for real"
6
+ end
7
+ end
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Daniel DeLeo
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
20
+
@@ -0,0 +1,102 @@
1
+ = Qusion
2
+ Qusion makes AMQP[http://github.com/tmm1/amqp] work with your webserver with no fuss. It's a simple library/plugin with three features:
3
+ * A set of monkey patches that sets up the required callbacks and/or worker threads so that AMQP will work with Passenger, Thin, or Mongrel. WEBrick, SCGI, and Evented Mongrel are experimentally supported, but not heavily tested.
4
+ * A Channel Pool. You can cause problems for yourself if you create new channels (with MQ.new) for every request. The pool sets up a few of these when your app starts and reuses them.
5
+ * YAML configuration files. If you're using Rails or Merb, create config/amqp.yml, then fill in the details for development, test, and production. Use Qusion.start() in your environment.rb file and you're good to go.
6
+
7
+ = Before You Start
8
+ Qusion makes it easy to just install the plugin and start using AMQP in your application. But there are many ways to use background jobs within a Rails app, so it's worth taking some time to consider the tradeoffs of each approach.
9
+
10
+ * If your background job needs are simple and you're using a relational database, Delayed::Job[http://github.com/tobi/delayed_job/] lets you schedule background tasks through the database. You won't need to run another application (the AMQP Broker) to keep your app running.
11
+ * The 0.6.x version of the ruby amqp library may drop messages when the AMQP broker goes down. Pivotal Labs has discussed this problem on their blog[http://pivots.pivotallabs.com/users/will/blog/articles/966-how-to-not-test-rabbitmq-part-1]. This issue will likely be addressed in the 0.7.0 release of amqp, but can be avoided entirely using a synchronous amqp library such as bunny[http://github.com/celldee/bunny]. For a ready-made background job solution using Bunny to publish jobs to the queue, see Minion[http://github.com/orionz/minion/].
12
+ * Qusion runs EventMachine in a separate thread on Phusion Passenger, Mongrel, and other non-evented servers. There are some inefficiencies in Ruby 1.8's threading model that make running EM in a thread quite slow. Joe Damato and Aman Gupta have created a patch[http://github.com/ice799/matzruby/tree/heap_stacks] for the problem which is included in an experimental branch of REE. You can learn more about the patch from Phusion's Blog[http://blog.phusion.nl/2009/12/15/google-tech-talk-on-ruby-enterprise-edition/].
13
+
14
+ = Getting Started
15
+ First you'll need the amqp library and a working RabbitMQ installation. This entails:
16
+ * Install Erlang for your platform
17
+ * Install RabbitMQ for your platform
18
+ * (sudo) gem install amqp
19
+ Ezmobius has a good walk-through on the readme for nanite[http://github.com/ezmobius/nanite/] if you haven't done this yet.
20
+ == Install Qusion
21
+ Start by installing Qusion as a plugin:
22
+
23
+ script/plugin install git://github.com/danielsdeleo/qusion.git
24
+
25
+ Next, in your config/environment.rb, add something like:
26
+
27
+ # Add eventmachine and amqp gems to config.gem to get config.gem goodies:
28
+ config.gem "eventmachine"
29
+ config.gem "amqp"
30
+
31
+ # Start AMQP after rails loads:
32
+ config.after_initialize do
33
+ Qusion.start # no options needed if you're using config/amqp.yml or the default settings.
34
+ end
35
+
36
+ And that's it! This will set up AMQP for any ruby app server (tested on mongrel, thin, and passenger). Now, you can use all of AMQP's functionality as normal. In your controllers or models, you might have:
37
+
38
+ MQ.new.queue("my-work-queue").publish("do work, son!")
39
+
40
+ and it should just work.
41
+
42
+ = Channel Pools
43
+ It's considered bad practice to use MQ.new over and over, as it creates a new AMQP channel, and that creates a new Erlang process in RabbitMQ. Erlang processes are super light weight, but you'll be wasting them and causing the Erlang VM GC headaches if you create them wantonly. So don't do that. Instead, use the channel pool provided by Qusion. It's simple: wherever you'd normally put MQ.new, just replace it with Qusion.channel. Examples:
44
+
45
+ # Create a queue:
46
+ Qusion.channel.queue("my-worker-queue")
47
+ # Topics:
48
+ Qusion.channel.topic("my-topic-exchange")
49
+ # etc.
50
+
51
+ This feature is a bit experimental, so the optimal pool size isn't known yet. The default is 5. You can change it by adding something like the following to your environment.rb:
52
+
53
+ Qusion.channel_pool_size(3)
54
+
55
+ = Configuration
56
+ If you're using rails or merb, you can put your AMQP server details in config/amqp.yml and Qusion will load it when you call Qusion.start(). Example:
57
+
58
+ # Put this in config/amqp.yml
59
+ development:
60
+ host: localhost
61
+ port: 5672
62
+ user: guest
63
+ pass: guest
64
+ vhost: /
65
+ timeout: 3600
66
+ logging: false
67
+ ssl: false
68
+
69
+ test:
70
+ host: localhost
71
+ port: 5672
72
+ ...
73
+
74
+ production:
75
+ host: localhost
76
+ port: 5672
77
+ ...
78
+
79
+ If you're too hardcore for rails or merb (maybe you're using Sinatra or Ramaze), you can still use a YAML config file, but there's no support for different environments. So do something like this:
80
+
81
+ # Tell Qusion where your config file is:
82
+ Qusion.start("/path/to/amqp.yml")
83
+
84
+ # Your configuration looks like this:
85
+ application:
86
+ host: localhost
87
+ port: 5672
88
+ ...
89
+
90
+ If you just want to get started without configuring anything, Qusion.start() will use the default options if it can't find a config file. And, finally, you can give options directly to Qusion.start() like this:
91
+
92
+ Qusion.start(:host => "my-amqp-broker.mydomain.com", :user => "me", :pass => "am_I_really_putting_this_in_VCS?")
93
+
94
+
95
+ = Bugs? Hacking?
96
+ If you find any bugs, or feel the need to add a feature, fork away. You can also contact me directly via the email address in my profile if you have any quesions.
97
+
98
+ = Shouts
99
+ * Qusion's code for Phusion Passenger's starting_worker_process event was originally posted by Aman Gupta (tmm1[http://github.com/tmm1]) on the AMQP list[http://groups.google.com/group/ruby-amqp]
100
+ * Brightbox's Warren[http://github.com/brightbox/warren] library provides some similar functionality. It doesn't support webserver-specific EventMachine setup, but it does have built-in encryption and support for the synchronous (non-EventMachine) Bunny[http://github.com/celldee/bunny] AMQP client.
101
+
102
+ dan@kallistec.com
@@ -0,0 +1,35 @@
1
+ # encoding: UTF-8
2
+ require "spec/rake/spectask"
3
+ require "cucumber"
4
+ require "cucumber/rake/task"
5
+
6
+ task :default => :spec
7
+
8
+ desc "Run Cucumber Features"
9
+ Cucumber::Rake::Task.new do |t|
10
+ t.cucumber_opts = "-c -n"
11
+ end
12
+
13
+ desc "Run all of the specs"
14
+ Spec::Rake::SpecTask.new do |t|
15
+ t.spec_opts = ['--options', "spec/spec.opts"]
16
+ t.fail_on_error = false
17
+ end
18
+
19
+ namespace :spec do
20
+
21
+ desc "Generate HTML report for failing examples"
22
+ Spec::Rake::SpecTask.new('report') do |t|
23
+ t.spec_files = FileList['failing_examples/**/*.rb']
24
+ t.spec_opts = ["--format", "html:doc/tools/reports/failing_examples.html", "--diff", '--options', '"spec/spec.opts"']
25
+ t.fail_on_error = false
26
+ end
27
+
28
+ desc "Run all spec with RCov"
29
+ Spec::Rake::SpecTask.new(:rcov) do |t|
30
+ t.rcov = true
31
+ t.rcov_dir = 'doc/tools/coverage/'
32
+ t.rcov_opts = ['--exclude', 'spec']
33
+ end
34
+
35
+ end
@@ -0,0 +1 @@
1
+ # Don't need to do anything special.
@@ -0,0 +1 @@
1
+ # Nothing to see here (yet), folks
@@ -0,0 +1,28 @@
1
+ # encoding: UTF-8
2
+ unless defined?(QUSION_ROOT)
3
+ QUSION_ROOT = File.dirname(__FILE__) + '/'
4
+ end
5
+
6
+ require "eventmachine"
7
+ require "mq"
8
+
9
+ require QUSION_ROOT + "qusion/server_spy"
10
+ require QUSION_ROOT + "qusion/em"
11
+ require QUSION_ROOT + "qusion/amqp"
12
+ require QUSION_ROOT + "qusion/channel_pool"
13
+ require QUSION_ROOT + "qusion/amqp_config"
14
+
15
+ module Qusion
16
+ def self.start(*opts)
17
+ amqp_opts = AmqpConfig.new(*opts).config_opts
18
+ AMQP.start_web_dispatcher(amqp_opts)
19
+ end
20
+
21
+ def self.channel
22
+ ChannelPool.instance.channel
23
+ end
24
+
25
+ def self.channel_pool_size(new_pool_size)
26
+ ChannelPool.pool_size = new_pool_size
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ # encoding: UTF-8
2
+
3
+ module AMQP
4
+ def self.start_web_dispatcher(amqp_settings={})
5
+ @settings = settings.merge(amqp_settings)
6
+ case Qusion::ServerSpy.server_type
7
+ when :passenger
8
+ PhusionPassenger.on_event(:starting_worker_process) do |forked|
9
+ if forked
10
+ EM.kill_reactor
11
+ Thread.current[:mq], @conn = nil, nil
12
+ end
13
+ Thread.new { start }
14
+ die_gracefully_on_signal
15
+ end
16
+ when :standard
17
+ Thread.new { start }
18
+ die_gracefully_on_signal
19
+ when :evented
20
+ die_gracefully_on_signal
21
+ when :none
22
+ else
23
+ raise ArgumentError, "AMQP#start_web_dispatcher requires an argument of [:standard|:evented|:passenger|:none]"
24
+ end
25
+ end
26
+
27
+ def self.die_gracefully_on_signal
28
+ Signal.trap("INT") { AMQP.stop { EM.stop } }
29
+ Signal.trap("TERM") { AMQP.stop { EM.stop } }
30
+ end
31
+ end
@@ -0,0 +1,70 @@
1
+ # encoding: UTF-8
2
+ require 'yaml'
3
+
4
+ module Qusion
5
+
6
+ class AmqpConfig
7
+ attr_reader :config_path, :framework_env
8
+
9
+ def initialize(opts=nil)
10
+ if opts && opts.respond_to?(:keys)
11
+ @config_path = nil
12
+ @config_opts = opts
13
+ elsif opts
14
+ @config_path = opts
15
+ else
16
+ load_framework_config
17
+ end
18
+ end
19
+
20
+ def load_framework_config
21
+ if defined?(RAILS_ROOT)
22
+ @config_path = RAILS_ROOT + "/config/amqp.yml"
23
+ @framework_env = RAILS_ENV
24
+ elsif defined?(Merb)
25
+ @config_path = Merb.root + "/config/amqp.yml"
26
+ @framework_env = Merb.environment
27
+ else
28
+ nil
29
+ end
30
+ end
31
+
32
+ def config_opts
33
+ @config_opts ||= load_config_opts
34
+ end
35
+
36
+ def load_config_opts
37
+ if config_path && config_from_yaml = load_amqp_config_file
38
+ if framework_env
39
+ framework_amqp_opts(config_from_yaml)
40
+ else
41
+ amqp_opts(config_from_yaml)
42
+ end
43
+ else
44
+ {}
45
+ end
46
+ end
47
+
48
+ def framework_amqp_opts(config_hash)
49
+ symbolize_keys(config_hash[framework_env.to_s])
50
+ end
51
+
52
+ def amqp_opts(config_hash)
53
+ symbolize_keys(config_hash.first.last)
54
+ end
55
+
56
+ def symbolize_keys(config_hash)
57
+ symbolized_hsh = {}
58
+ config_hash.each {|option, value| symbolized_hsh[option.to_sym] = value }
59
+ symbolized_hsh
60
+ end
61
+
62
+ def load_amqp_config_file
63
+ begin
64
+ YAML.load_file(config_path)
65
+ rescue Errno::ENOENT
66
+ end
67
+ end
68
+
69
+ end
70
+ end
@@ -0,0 +1,64 @@
1
+ # encoding: UTF-8
2
+ require "singleton"
3
+
4
+ module Qusion
5
+
6
+ # ChannelPool maintains a pool of AMQP channel objects that can be reused.
7
+ # The motivation behind this is that if you were to use MQ.new to create a
8
+ # new channel for every request, your AMQP broker could be swamped trying to
9
+ # maintain a bunch of channels that you're only using once.
10
+ #
11
+ # To use the channel pool, just replace <tt>MQ.new</tt> in your code with <tt>Qusion.channel</tt>
12
+ #
13
+ # # Instead of this:
14
+ # MQ.new.queue("my-worker-queue")
15
+ # # Do this:
16
+ # Qusion.channel.queue("my-worker-queue")
17
+ #
18
+ # By default, ChannelPool maintains a pool of 5 channels. This can be adjusted with
19
+ # <tt>ChannelPool.pool_size=()</tt> or <tt>Qusion.channel_pool_size()</tt>
20
+ # The optimal pool size is not yet known, but I suspect you might need a
21
+ # larger value if using Thin in production, and a smaller value otherwise.
22
+ class ChannelPool
23
+ include Singleton
24
+
25
+ class << self
26
+
27
+ def pool_size=(new_pool_size)
28
+ reset
29
+ @pool_size = new_pool_size
30
+ end
31
+
32
+ def pool_size
33
+ @pool_size ||= 5
34
+ end
35
+
36
+ def reset
37
+ @pool_size = nil
38
+ instance.reset
39
+ end
40
+
41
+ end
42
+
43
+ attr_reader :pool
44
+
45
+ def channel
46
+ @i ||= 1
47
+ @i = (@i + 1) % pool_size
48
+ pool[@i]
49
+ end
50
+
51
+ def pool
52
+ @pool ||= Array.new(pool_size) { MQ.new }
53
+ end
54
+
55
+ def reset
56
+ @pool = nil
57
+ end
58
+
59
+ def pool_size
60
+ self.class.pool_size
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,13 @@
1
+ # encoding: UTF-8
2
+
3
+ module EM
4
+
5
+ def self.kill_reactor
6
+ if self.reactor_running?
7
+ self.stop_event_loop
8
+ self.release_machine
9
+ @reactor_running = false
10
+ end
11
+ end
12
+
13
+ end
@@ -0,0 +1,24 @@
1
+ # encoding: UTF-8
2
+
3
+ module Qusion
4
+ module ServerSpy
5
+ extend self
6
+ def server_type
7
+ if defined?(::PhusionPassenger)
8
+ :passenger
9
+ elsif defined?(::Mongrel) && defined?(::Mongrel::MongrelProtocol)
10
+ :evented
11
+ elsif defined?(::Mongrel)
12
+ :standard
13
+ elsif defined?(::SCGI)
14
+ :standard
15
+ elsif defined?(::WEBrick)
16
+ :standard
17
+ elsif defined?(::Thin)
18
+ :evented
19
+ else
20
+ :none
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,28 @@
1
+ development:
2
+ host: localhost
3
+ port: 5672
4
+ user: guest
5
+ pass: guest
6
+ vhost: /
7
+ timeout: 600
8
+ logging: false
9
+ ssl: false
10
+
11
+ test:
12
+ host: localhost
13
+ port: 5672
14
+ user: guest
15
+ pass: guest
16
+ vhost: /
17
+ logging: false
18
+ ssl: false
19
+
20
+ production:
21
+ host: localhost
22
+ port: 5672
23
+ user: guest
24
+ pass: guest
25
+ vhost: /
26
+ timeout: 3600
27
+ logging: false
28
+ ssl: false
@@ -0,0 +1,9 @@
1
+ application:
2
+ host: localhost
3
+ port: 5672
4
+ user: guest
5
+ pass: guest
6
+ vhost: /
7
+ timeout: 600
8
+ logging: false
9
+ ssl: false
@@ -0,0 +1 @@
1
+ -f specdoc -c -t 2
@@ -0,0 +1,10 @@
1
+ # encoding: UTF-8
2
+ begin
3
+ require 'rubygems'
4
+ rescue LoadError
5
+ end
6
+
7
+ require File.dirname(__FILE__) + '/../lib/qusion.rb'
8
+
9
+ include Qusion
10
+
@@ -0,0 +1,63 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe AmqpConfig do
5
+
6
+ after(:each) do
7
+ Object.send(:remove_const, :RAILS_ROOT) if defined? ::RAILS_ROOT
8
+ Object.send(:remove_const, :Merb) if defined? ::Merb
9
+ end
10
+
11
+ it "should use RAILS_ROOT/config/amqp.yml if RAILS_ROOT is defined" do
12
+ ::RAILS_ROOT = "/path/to/rails"
13
+ ::RAILS_ENV = nil
14
+ AmqpConfig.new.config_path.should == "/path/to/rails/config/amqp.yml"
15
+ end
16
+
17
+ it "should use \#{Merb.root}/config/amqp.yml if RAILS_ROOT is undefined and Merb is defined" do
18
+ ::Merb = mock("merby")
19
+ ::Merb.should_receive(:root).and_return("/path/to/merb")
20
+ ::Merb.should_receive(:environment).and_return(nil)
21
+ AmqpConfig.new.config_path.should == "/path/to/merb/config/amqp.yml"
22
+ end
23
+
24
+ it "should use the provided path no matter what" do
25
+ ::RAILS_ROOT = nil
26
+ ::Merb = nil
27
+ path = AmqpConfig.new("/custom/path/to/amqp.yml").config_path
28
+ path.should == "/custom/path/to/amqp.yml"
29
+ end
30
+
31
+ it "should use a provided options hash if given" do
32
+ ::RAILS_ROOT = nil
33
+ ::Merb = nil
34
+ conf = AmqpConfig.new(:host => "my-broker.mydomain.com")
35
+ conf.config_path.should be_nil
36
+ conf.config_opts.should == {:host => "my-broker.mydomain.com"}
37
+ end
38
+
39
+ it "should use the default amqp options in rails if amqp.yml doesn't exist" do
40
+ ::RAILS_ROOT = File.dirname(__FILE__) + '/../'
41
+ AmqpConfig.new.config_opts.should == {}
42
+ end
43
+
44
+ it "should load a YAML file when using a framework" do
45
+ conf = AmqpConfig.new
46
+ conf.stub!(:config_path).and_return(File.dirname(__FILE__) + "/../fixtures/framework-amqp.yml")
47
+ conf.stub!(:framework_env).and_return("production")
48
+ conf.config_opts.should == {:host => 'localhost',:port => 5672,
49
+ :user => 'guest', :pass => 'guest',
50
+ :vhost => '/', :timeout => 3600,
51
+ :logging => false, :ssl => false}
52
+ end
53
+
54
+ it "should use the first set of opts when given a explicit file path" do
55
+ conf = AmqpConfig.new
56
+ conf.stub!(:config_path).and_return(File.dirname(__FILE__) + "/../fixtures/hardcoded-amqp.yml")
57
+ conf.config_opts.should == {:host => 'localhost',:port => 5672,
58
+ :user => 'guest', :pass => 'guest',
59
+ :vhost => '/', :timeout => 600,
60
+ :logging => false, :ssl => false}
61
+ end
62
+
63
+ end
@@ -0,0 +1,42 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe AMQP do
5
+
6
+ before do
7
+ AMQP.stub!(:settings).and_return({})
8
+ end
9
+
10
+ after(:each) do
11
+ Object.send(:remove_const, :PhusionPassenger) if defined? ::PhusionPassenger
12
+ Object.send(:remove_const, :Thin) if defined? ::Thin
13
+ Object.send(:remove_const, :Mongrel) if defined? ::Mongrel
14
+ end
15
+
16
+ it "should kill the reactor and start a new AMQP connection when forked in Passenger" do
17
+ AMQP.should_receive(:die_gracefully_on_signal)
18
+ ::PhusionPassenger = Module.new
19
+ forked = mock("starting_worker_process_callback_obj")
20
+ ::PhusionPassenger.should_receive(:on_event).with(:starting_worker_process).and_yield(forked)
21
+ EM.should_receive(:kill_reactor)
22
+ AMQP.should_receive(:start)
23
+ AMQP.start_web_dispatcher
24
+ sleep 0.1 # give the thread time to run, esp. on ruby 1.9
25
+ end
26
+
27
+ it "should set AMQP's connection settings when running under Thin" do
28
+ AMQP.should_receive(:die_gracefully_on_signal)
29
+ ::Thin = Module.new
30
+ AMQP.start_web_dispatcher({:cookie => "yummy"})
31
+ AMQP.instance_variable_get(:@settings)[:cookie].should == "yummy"
32
+ end
33
+
34
+ it "should start a worker thread when running under Mongrel" do
35
+ AMQP.should_receive(:die_gracefully_on_signal)
36
+ ::Mongrel = Module.new
37
+ AMQP.should_receive(:start)
38
+ AMQP.start_web_dispatcher
39
+ sleep 0.1 # give the thread time to run, esp. on ruby 1.9
40
+ end
41
+
42
+ end
@@ -0,0 +1,48 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe ChannelPool do
5
+ MQ = Object.new
6
+
7
+ before(:each) do
8
+ ChannelPool.reset
9
+ @channel_pool = ChannelPool.instance
10
+ end
11
+
12
+ it "should be singleton" do
13
+ lambda { ChannelPool.new }.should raise_error
14
+ end
15
+
16
+ it "should adjust the pool size" do
17
+ ChannelPool.pool_size = 5
18
+ ChannelPool.pool_size.should == 5
19
+ end
20
+
21
+ it "should reset itself when the pool size is set" do
22
+ ChannelPool.should_receive(:reset)
23
+ ChannelPool.pool_size = 23
24
+ end
25
+
26
+ it "should create a pool of AMQP channels" do
27
+ ChannelPool.pool_size = 3
28
+ ::MQ.should_receive(:new).exactly(3).times
29
+ @channel_pool.pool
30
+ end
31
+
32
+ it "should default to a pool size of 5" do
33
+ ::MQ.should_receive(:new).exactly(5).times.and_return("swanky")
34
+ @channel_pool.pool
35
+ @channel_pool.instance_variable_get(:@pool).should == %w{ swanky swanky swanky swanky swanky}
36
+ end
37
+
38
+ it "should return a channel in a round-robin" do
39
+ @channel_pool.instance_variable_set(:@pool, [1,2,3,4,5])
40
+ @channel_pool.channel.should == 3
41
+ @channel_pool.channel.should == 4
42
+ @channel_pool.channel.should == 5
43
+ @channel_pool.channel.should == 1
44
+ @channel_pool.channel.should == 2
45
+ @channel_pool.channel.should == 3
46
+ end
47
+
48
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe EM do
5
+
6
+ it "should kill the reactor for forking" do
7
+ EM.should_receive(:reactor_running?).and_return(true)
8
+ EM.should_receive(:stop_event_loop)
9
+ EM.should_receive(:release_machine)
10
+ EM.kill_reactor
11
+ EM.instance_variable_get(:@reactor_running).should be_false
12
+ end
13
+
14
+ end
@@ -0,0 +1,26 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe "Qusion Convenience Methods" do
5
+
6
+ it "should get a channel from the pool" do
7
+ channel_pool = mock("channel pool")
8
+ ChannelPool.should_receive(:instance).and_return(channel_pool)
9
+ channel_pool.should_receive(:channel)
10
+ Qusion.channel
11
+ end
12
+
13
+ it "should set the channel pool size" do
14
+ ChannelPool.should_receive(:pool_size=).with(7)
15
+ Qusion.channel_pool_size(7)
16
+ end
17
+
18
+ it "should load the configuration and setup AMQP for the webserver" do
19
+ config = mock("config")
20
+ AmqpConfig.should_receive(:new).and_return(config)
21
+ config.should_receive(:config_opts).and_return("tasty cookie")
22
+ AMQP.should_receive(:start_web_dispatcher).with("tasty cookie")
23
+ Qusion.start
24
+ end
25
+
26
+ end
@@ -0,0 +1,63 @@
1
+ # encoding: UTF-8
2
+ require File.dirname(__FILE__) + "/../spec_helper"
3
+
4
+ describe ServerSpy do
5
+
6
+ after do
7
+ Object.send(:remove_const, :SCGI) if defined? ::SCGI
8
+ Object.send(:remove_const, :WEBrick) if defined? ::WEBrick
9
+ Object.send(:remove_const, :PhusionPassenger) if defined? ::PhusionPassenger
10
+ Object.send(:remove_const, :Thin) if defined? ::Thin
11
+ Mongrel.send(:remove_const, :MongrelProtocol) if defined?(::Mongrel::MongrelProtocol)
12
+ Object.send(:remove_const, :Mongrel) if defined? ::Mongrel
13
+ end
14
+
15
+ it "maps evented mongrel to :evented" do
16
+ ::Mongrel = Module.new
17
+ ::Mongrel::MongrelProtocol = Module.new
18
+ ServerSpy.server_type.should == :evented
19
+ end
20
+
21
+ it "maps Mongrel to :standard" do
22
+ ::Mongrel = Module.new
23
+ ServerSpy.server_type.should == :standard
24
+ end
25
+
26
+ it "maps WEBrick to :standard" do
27
+ ::WEBrick = Module.new
28
+ ServerSpy.server_type.should == :standard
29
+ end
30
+
31
+ it "maps SCGI to :standard" do
32
+ ::SCGI = Module.new
33
+ ServerSpy.server_type.should == :standard
34
+ end
35
+
36
+ it "maps PhusionPassenger to :passenger" do
37
+ ::PhusionPassenger = Module.new
38
+ ServerSpy.server_type.should == :passenger
39
+ end
40
+
41
+ it "maps Thin to :evented" do
42
+ ::Thin = Module.new
43
+ ServerSpy.server_type.should == :evented
44
+ end
45
+
46
+ # Rails after 2.2(?) to edge circa Aug 2009 loads thin if it's installed no matter what
47
+ it "gives the server type as :standard if both Thin and Mongrel are defined" do
48
+ ::Mongrel = Module.new
49
+ ::Thin = Module.new
50
+ ServerSpy.server_type.should == :standard
51
+ end
52
+
53
+ it "gives the server type as :passenger if both Thin and PhusionPassenger" do
54
+ ::PhusionPassenger = Module.new
55
+ ::Thin = Module.new
56
+ ServerSpy.server_type.should == :passenger
57
+ end
58
+
59
+ it "gives the server type as :none if no supported server is found" do
60
+ ServerSpy.server_type.should == :none
61
+ end
62
+
63
+ end
metadata ADDED
@@ -0,0 +1,114 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: gongren
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - "Fran\xC3\xA7ois Beausoleil"
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2010-01-16 00:00:00 -05:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: thoughtbot-shoulda
17
+ type: :development
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: yard
27
+ type: :development
28
+ version_requirement:
29
+ version_requirements: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: "0"
34
+ version:
35
+ - !ruby/object:Gem::Dependency
36
+ name: amqp
37
+ type: :runtime
38
+ version_requirement:
39
+ version_requirements: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ~>
42
+ - !ruby/object:Gem::Version
43
+ version: 0.6.6
44
+ version:
45
+ description: A gem that's currently tied to Rails to distribute jobs.
46
+ email: francois@teksol.info
47
+ executables: []
48
+
49
+ extensions: []
50
+
51
+ extra_rdoc_files:
52
+ - LICENSE
53
+ - README.rdoc
54
+ files:
55
+ - LICENSE
56
+ - README.rdoc
57
+ - Rakefile
58
+ - lib/gongren.rb
59
+ - lib/gongren/server.rb
60
+ - lib/gongren/worker.rb
61
+ - vendor/qusion/LICENSE
62
+ - vendor/qusion/README.rdoc
63
+ - vendor/qusion/Rakefile
64
+ - vendor/qusion/init.rb
65
+ - vendor/qusion/install.rb
66
+ - vendor/qusion/lib/qusion.rb
67
+ - vendor/qusion/lib/qusion/amqp.rb
68
+ - vendor/qusion/lib/qusion/amqp_config.rb
69
+ - vendor/qusion/lib/qusion/channel_pool.rb
70
+ - vendor/qusion/lib/qusion/em.rb
71
+ - vendor/qusion/lib/qusion/server_spy.rb
72
+ - vendor/qusion/spec/fixtures/framework-amqp.yml
73
+ - vendor/qusion/spec/fixtures/hardcoded-amqp.yml
74
+ - vendor/qusion/spec/spec.opts
75
+ - vendor/qusion/spec/spec_helper.rb
76
+ - vendor/qusion/spec/unit/amqp_config_spec.rb
77
+ - vendor/qusion/spec/unit/amqp_spec.rb
78
+ - vendor/qusion/spec/unit/channel_pool_spec.rb
79
+ - vendor/qusion/spec/unit/em_spec.rb
80
+ - vendor/qusion/spec/unit/qusion_spec.rb
81
+ - vendor/qusion/spec/unit/server_spy_spec.rb
82
+ has_rdoc: true
83
+ homepage: http://github.com/francois/gongren
84
+ licenses: []
85
+
86
+ post_install_message:
87
+ rdoc_options:
88
+ - --charset=UTF-8
89
+ require_paths:
90
+ - lib
91
+ - vendor/qusion/lib
92
+ - vendor/qusion/lib
93
+ required_ruby_version: !ruby/object:Gem::Requirement
94
+ requirements:
95
+ - - ">="
96
+ - !ruby/object:Gem::Version
97
+ version: "0"
98
+ version:
99
+ required_rubygems_version: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - ">="
102
+ - !ruby/object:Gem::Version
103
+ version: "0"
104
+ version:
105
+ requirements: []
106
+
107
+ rubyforge_project:
108
+ rubygems_version: 1.3.5
109
+ signing_key:
110
+ specification_version: 3
111
+ summary: Gongren distributes jobs to workers, with support for failed worker daemons and load balancing.
112
+ test_files:
113
+ - test/helper.rb
114
+ - test/test_gongren.rb