├── .gitignore ├── lib ├── woodhouse │ ├── version.rb │ ├── runners │ │ ├── dummy_runner.rb │ │ ├── bunny_runner.rb │ │ ├── file_runner.rb │ │ └── hot_bunnies_runner.rb │ ├── middleware │ │ ├── assign_logger.rb │ │ ├── airbrake_exceptions.rb │ │ ├── log_dispatch.rb │ │ └── log_jobs.rb │ ├── registry.rb │ ├── dispatchers │ │ ├── local_dispatcher.rb │ │ ├── local_pool_dispatcher.rb │ │ ├── common_amqp_dispatcher.rb │ │ ├── test_dispatcher.rb │ │ ├── bunny_dispatcher.rb │ │ ├── hot_bunnies_dispatcher.rb │ │ └── file_dispatcher.rb │ ├── middleware.rb │ ├── trigger_set.rb │ ├── runners.rb │ ├── extensions │ │ ├── new_relic │ │ │ └── instrumentation_middleware.rb │ │ ├── new_relic.rb │ │ └── progress.rb │ ├── mixin_registry.rb │ ├── rails2.rb │ ├── dispatchers.rb │ ├── middleware_stack.rb │ ├── extension.rb │ ├── dispatcher.rb │ ├── queue_criteria.rb │ ├── process.rb │ ├── rails.rb │ ├── layout_builder.rb │ ├── job.rb │ ├── job_execution.rb │ ├── layout_serializer.rb │ ├── server.rb │ ├── runner.rb │ ├── watchdog.rb │ ├── worker.rb │ ├── scheduler.rb │ ├── node_configuration.rb │ └── layout.rb ├── generators │ └── woodhouse_generator.rb └── woodhouse.rb ├── .travis.yml ├── Gemfile ├── Guardfile ├── PROGRESS-NOTES.txt ├── doc └── example │ ├── script-woodhouse │ └── woodhouse-initializer.rb ├── spec ├── mixin_registry_spec.rb ├── node_configuration_spec.rb ├── test_dispatcher_spec.rb ├── worker_spec.rb ├── queue_criteria_spec.rb ├── integration │ └── bunny_worker_process_spec.rb ├── progress_spec.rb ├── scheduler_spec.rb ├── middleware_stack_spec.rb ├── layout_builder_spec.rb ├── shared_contexts.rb ├── server_spec.rb └── layout_spec.rb ├── Rakefile ├── MIT-LICENSE ├── woodhouse.gemspec ├── README.markdown └── THOUGHTS /.gitignore: -------------------------------------------------------------------------------- 1 | *.swp 2 | .bundle 3 | .binstubs 4 | Gemfile.lock 5 | html/ 6 | -------------------------------------------------------------------------------- /lib/woodhouse/version.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse 2 | VERSION = "2.0.0pre1" 3 | end 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 1.9.3 4 | - 2.0.0 5 | - 2.1.1 6 | - jruby-1.7.11 7 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | platforms :jruby do 4 | gem 'hot_bunnies' 5 | end 6 | 7 | gemspec 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'rspec', :version => 1 do 2 | watch(%r{spec/.+_spec\.rb}) 3 | watch(%r{lib/(.+?)/(.+)\.rb}) { |m| "spec/#{m[1]}_spec.rb" } 4 | end 5 | -------------------------------------------------------------------------------- /PROGRESS-NOTES.txt: -------------------------------------------------------------------------------- 1 | # Progress Notifications 2 | 3 | * Jobs have a _progress_id added to the arguments 4 | * A direct exchange 'woodhouse.jobs' is created 5 | * This _progress_id is used as the routing key 6 | -------------------------------------------------------------------------------- /lib/woodhouse/runners/dummy_runner.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Runners::DummyRunner < Woodhouse::Runner 2 | 3 | def subscribe 4 | wait :spin_down 5 | end 6 | 7 | def spin_down 8 | signal :spin_down 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware/assign_logger.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Middleware::AssignLogger < Woodhouse::Middleware 2 | 3 | def call(job, worker) 4 | if @config.logger and worker.respond_to?(:logger) 5 | worker.logger = @config.logger 6 | end 7 | yield job, worker 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware/airbrake_exceptions.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Middleware::AirbrakeExceptions < Woodhouse::Middleware 2 | 3 | def call(job, worker) 4 | begin 5 | yield job, worker 6 | rescue => err 7 | Airbrake.notify(err) 8 | raise err 9 | end 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /lib/woodhouse/registry.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Registry 2 | include Woodhouse::Util 3 | 4 | def [](worker) 5 | raise NotImplementedError, "subclass Woodhouse::Registry and override #[]" 6 | end 7 | 8 | def each 9 | raise NotImplementedError, "subclass Woodhouse::Registry and override #each" 10 | end 11 | 12 | end 13 | -------------------------------------------------------------------------------- /doc/example/script-woodhouse: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require File.dirname(__FILE__) + '/../config/environment' 3 | 4 | logg = Logger.new(File.dirname(__FILE__) + "/../log/woodhouse.log") 5 | logg.level = Logger::DEBUG 6 | Woodhouse.global_configuration.logger = logg 7 | Celluloid.logger = logg 8 | 9 | Woodhouse::Process.new.execute 10 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/local_dispatcher.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Dispatchers::LocalDispatcher < Woodhouse::Dispatcher 2 | 3 | private 4 | 5 | def deliver_job(job) 6 | Woodhouse::JobExecution.new(@config, job).execute 7 | end 8 | 9 | def deliver_job_update(job, data) 10 | @config.logger.info "[Woodhouse job update] #{job.job_id} -- #{data.inspect}" 11 | end 12 | 13 | end 14 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Middleware 2 | 3 | def initialize(config) 4 | @config = config 5 | end 6 | 7 | def call(*args) 8 | yield *args 9 | end 10 | 11 | end 12 | 13 | require 'woodhouse/middleware/log_jobs' 14 | require 'woodhouse/middleware/log_dispatch' 15 | require 'woodhouse/middleware/assign_logger' 16 | require 'woodhouse/middleware/airbrake_exceptions' 17 | -------------------------------------------------------------------------------- /lib/woodhouse/trigger_set.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::TriggerSet 2 | 3 | def initialize 4 | @triggers = {} 5 | end 6 | 7 | def add(event_name, &blk) 8 | @triggers[event_name.to_sym] ||= [] 9 | @triggers[event_name.to_sym] << blk 10 | end 11 | 12 | 13 | def trigger(event_name, *args) 14 | (@triggers[event_name.to_sym] || []).each do |trigger| 15 | trigger.call(*args) 16 | end 17 | end 18 | 19 | end 20 | -------------------------------------------------------------------------------- /doc/example/woodhouse-initializer.rb: -------------------------------------------------------------------------------- 1 | Woodhouse.configure do |woodhouse| 2 | woodhouse.runner_middleware << Woodhouse::Middleware::AirbrakeExceptions 3 | end 4 | 5 | Woodhouse.layout do |layout| 6 | layout.node(:default) do |node| 7 | node.all_workers 8 | node.remove :ImportWorker 9 | node.add :ImportWorker, :threads => 2, :only => { :format => "csv" } 10 | node.add :ImportWorker, :threads => 3, :only => { :format => "xml" } 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/woodhouse/runners.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse::Runners 2 | 3 | def self.guess 4 | if defined? ::JRUBY_VERSION 5 | Woodhouse::Runners::HotBunniesRunner 6 | else 7 | Woodhouse::Runners::BunnyRunner 8 | end 9 | end 10 | 11 | end 12 | 13 | require 'woodhouse/runner' 14 | require 'woodhouse/runners/bunny_runner' 15 | require 'woodhouse/runners/hot_bunnies_runner' 16 | require 'woodhouse/runners/dummy_runner' 17 | require 'woodhouse/runners/file_runner' 18 | -------------------------------------------------------------------------------- /lib/woodhouse/extensions/new_relic/instrumentation_middleware.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::NewRelic::InstrumentationMiddleware < Woodhouse::Middleware 2 | include NewRelic::Agent::Instrumentation::ControllerInstrumentation 3 | 4 | def call(job, worker) 5 | perform_action_with_newrelic_trace(:name => job.job_method, :class_name => job.worker_class_name, :params => job.arguments, :category => :task, :path => job.queue_name) do 6 | yield job, worker 7 | end 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware/log_dispatch.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Middleware::LogDispatch < Woodhouse::Middleware 2 | 3 | def call(job) 4 | begin 5 | yield job 6 | rescue => err 7 | log "#{job.describe} could not be dispatched: #{err.inspect}" 8 | raise err 9 | end 10 | log "#{job.describe} dispatched" 11 | end 12 | 13 | private 14 | 15 | def log(msg) 16 | if @config.logger 17 | @config.logger.info msg 18 | end 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware/log_jobs.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Middleware::LogJobs < Woodhouse::Middleware 2 | 3 | def call(job, worker) 4 | log "#{job.describe} starting" 5 | begin 6 | yield job, worker 7 | rescue => err 8 | log "#{job.describe} failed: #{err.inspect}" 9 | raise err 10 | end 11 | log "#{job.describe} done" 12 | end 13 | 14 | private 15 | 16 | def log(msg) 17 | if @config.logger 18 | @config.logger.info msg 19 | end 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /spec/mixin_registry_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::MixinRegistry do 5 | 6 | subject { Woodhouse::MixinRegistry.new } 7 | 8 | it "should include all classes that include Woodhouse::Worker" do 9 | ::SomeFakeNewClass = Class.new 10 | SomeFakeNewClass.send(:include, Woodhouse::Worker) 11 | subject[:SomeFakeNewClass].should be SomeFakeNewClass 12 | Object.send :remove_const, :SomeFakeNewClass 13 | end 14 | 15 | end 16 | -------------------------------------------------------------------------------- /lib/woodhouse/mixin_registry.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::MixinRegistry < Woodhouse::Registry 2 | 3 | class << self 4 | 5 | def classes 6 | @classes ||= {} 7 | end 8 | 9 | def register(klass) 10 | register_worker klass.name, klass 11 | end 12 | 13 | def register_worker(class_name, klass) 14 | classes[class_name.to_s] = klass 15 | end 16 | 17 | end 18 | 19 | def [](worker) 20 | Woodhouse::MixinRegistry.classes[worker.to_s] 21 | end 22 | 23 | def each(&blk) 24 | Woodhouse::MixinRegistry.classes.each &blk 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /spec/node_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::NodeConfiguration do 5 | it_should_behave_like "common" 6 | 7 | subject { Woodhouse::NodeConfiguration.new } 8 | 9 | describe "server_info" do 10 | 11 | it "should default to an empty hash" do 12 | subject.server_info.should == {} 13 | end 14 | 15 | it "should convert keys into symbols" do 16 | subject.server_info = { "lana" => "LANAAAA" } 17 | subject.server_info.should have_key(:lana) 18 | end 19 | 20 | end 21 | 22 | end 23 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/local_pool_dispatcher.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Dispatchers::LocalPoolDispatcher < Woodhouse::Dispatcher 2 | 3 | class Worker 4 | include Celluloid 5 | 6 | def execute(executor) 7 | executor.execute 8 | end 9 | end 10 | 11 | private 12 | 13 | def after_initialize(config, opts = {}, &blk) 14 | @pool = Worker.pool(size: opts[:size] || 10) 15 | end 16 | 17 | def deliver_job(job) 18 | @pool.async.execute Woodhouse::JobExecution.new(@config, job) 19 | end 20 | 21 | def deliver_job_update(job, data) 22 | @config.logger.info "[Woodhouse job update] #{job.job_id} -- #{data.inspect}" 23 | end 24 | 25 | end 26 | -------------------------------------------------------------------------------- /lib/woodhouse/rails2.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | 3 | ActiveSupport::Dependencies.autoload_paths << RAILS_ROOT + "/app/workers" 4 | 5 | Woodhouse.configure do |config| 6 | config_paths = %w[woodhouse.yml workling.yml].map{|file| 7 | RAILS_ROOT + "/config/" + file 8 | } 9 | config.logger = ::Rails.logger 10 | if ::Rails.env =~ /development|test/ 11 | config.dispatcher_type = :local 12 | else 13 | config.dispatcher_type = :bunny 14 | end 15 | config_paths.each do |path| 16 | if File.exist?(path) 17 | config.server_info = YAML.load(File.read(path))[::Rails.env] 18 | end 19 | end 20 | config.runner_type = Woodhouse::Runners.guess 21 | end 22 | -------------------------------------------------------------------------------- /spec/test_dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | 3 | describe Woodhouse::Dispatchers::TestDispatcher do 4 | 5 | subject { Woodhouse::Dispatchers::TestDispatcher.new(Woodhouse::NodeConfiguration.new) } 6 | 7 | it "should store jobs" do 8 | subject.dispatch "PamPoovey", "shock_fights", "game_changer" => "yes" 9 | subject.dispatch "SterlingArcher", "spy", "on" => "Ramon Limon" 10 | 11 | subject.jobs.should have(2).items 12 | subject.jobs.first.worker_class_name.should == "PamPoovey" 13 | end 14 | 15 | it "should store job updates" do 16 | subject.update_job(:eating, "full" => "not yet") 17 | subject.job_updates.first.should == [ :eating, { "full" => "not yet" } ] 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse::Dispatchers 2 | 3 | def self.default_amqp_dispatcher 4 | if RUBY_ENGINE =~ /jruby/ 5 | Woodhouse::Dispatchers::HotBunniesDispatcher 6 | else 7 | Woodhouse::Dispatchers::BunnyDispatcher 8 | end 9 | end 10 | 11 | end 12 | 13 | require 'woodhouse/dispatcher' 14 | require 'woodhouse/dispatchers/local_dispatcher' 15 | require 'woodhouse/dispatchers/bunny_dispatcher' 16 | require 'woodhouse/dispatchers/hot_bunnies_dispatcher' 17 | require 'woodhouse/dispatchers/local_pool_dispatcher' 18 | require 'woodhouse/dispatchers/test_dispatcher' 19 | require 'woodhouse/dispatchers/file_dispatcher' 20 | 21 | Woodhouse::Dispatchers::AmqpDispatcher = Woodhouse::Dispatchers.default_amqp_dispatcher 22 | -------------------------------------------------------------------------------- /lib/woodhouse/extensions/new_relic.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | 3 | module Woodhouse::NewRelic 4 | 5 | class << self 6 | 7 | def install_extension(configuration, opts = {}, &blk) 8 | require 'woodhouse/extensions/new_relic/instrumentation_middleware' 9 | configuration.runner_middleware << Woodhouse::NewRelic::InstrumentationMiddleware 10 | configuration.at(:server_start) do 11 | ::NewRelic::Agent.manual_start 12 | configuration.logger.info "New Relic agent started." 13 | end 14 | configuration.at(:server_end) do 15 | ::NewRelic::Agent.shutdown 16 | configuration.logger.info "New Relic agent shut down." 17 | end 18 | end 19 | end 20 | 21 | end 22 | 23 | Woodhouse::Extension.register :new_relic, Woodhouse::NewRelic 24 | -------------------------------------------------------------------------------- /spec/worker_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::Worker do 5 | 6 | subject { 7 | Class.new do 8 | include Woodhouse::Worker 9 | def fake_job(*); end 10 | end 11 | } 12 | 13 | it "should provide class-level async_ convenience methods" do 14 | lambda do 15 | subject.async_fake_job 16 | end.should_not raise_error(NoMethodError) 17 | lambda do 18 | subject.async_something_else 19 | end.should raise_error(NoMethodError) 20 | lambda do 21 | subject.blah_blah_blah 22 | end.should raise_error(NoMethodError) 23 | lambda do 24 | subject.async_method # Don't want inherited methods to work 25 | end.should raise_error(NoMethodError) 26 | end 27 | 28 | end 29 | -------------------------------------------------------------------------------- /lib/woodhouse/middleware_stack.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::MiddlewareStack < Array 2 | 3 | def initialize(config) 4 | @config = config 5 | end 6 | 7 | def call(*args, &final) 8 | stack = make_stack.dup 9 | next_step = lambda {|*args| 10 | next_item = stack.shift 11 | if next_item.nil? 12 | final.call(*args) 13 | else 14 | next_item.call(*args, &next_step) 15 | end 16 | } 17 | next_step.call(*args) 18 | end 19 | 20 | private 21 | 22 | def make_stack 23 | @stack ||= 24 | map do |item| 25 | if item.respond_to?(:call) 26 | item 27 | elsif item.respond_to?(:new) 28 | item.new(@config) 29 | else 30 | raise ArgumentError, "bad entry #{item.inspect} in middleware stack" 31 | end 32 | end 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/woodhouse/extension.rb: -------------------------------------------------------------------------------- 1 | # Implements a very basic registry for Woodhouse extensions. This is a Class 2 | # rather than a Module because it will eventually be used to provide a more 3 | # structured approach than the one Woodhouse::Progress uses. 4 | class Woodhouse::Extension 5 | 6 | class << self 7 | 8 | attr_accessor :registry 9 | 10 | def register(name, extension) 11 | registry[name] = extension 12 | end 13 | 14 | def install_extension(name, configuration, opts = {}, &blk) 15 | if ext = registry[name] 16 | ext.install_extension(configuration, opts, &blk) 17 | else 18 | ext = load_extension(name) 19 | ext.install_extension(configuration, opts, &blk) 20 | end 21 | end 22 | 23 | def load_extension(name) 24 | require "woodhouse/extensions/#{name}" 25 | registry[name] 26 | end 27 | 28 | end 29 | 30 | self.registry = {} 31 | 32 | end 33 | -------------------------------------------------------------------------------- /spec/queue_criteria_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::QueueCriteria do 5 | it_should_behave_like "common" 6 | 7 | it "should stringify keys and values" do 8 | criteria = Woodhouse::QueueCriteria.new("abc" => :def, :fed => 1) 9 | criteria.criteria.should == { "abc" => "def", "fed" => "1" } 10 | end 11 | 12 | it "should expect all values to be matched" do 13 | criteria = Woodhouse::QueueCriteria.new(:orz => "*camper*", :spathi => "fwiffo") 14 | criteria.matches?("orz" => "*camper*").should be_false 15 | criteria.matches?("orz" => "*camper*", "spathi" => "fwiffo").should be_true 16 | criteria.matches?("orz" => "*camper*", "spathi" => "fwiffo", "vux" => "QRJ").should be_true 17 | criteria.exclusive = true 18 | criteria.matches?("orz" => "*camper*", "spathi" => "fwiffo", "vux" => "QRJ").should be_false 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/common_amqp_dispatcher.rb: -------------------------------------------------------------------------------- 1 | # Provides common behavior shared by the Bunny and HotBunnies dispatchers. 2 | class Woodhouse::Dispatchers::CommonAmqpDispatcher < Woodhouse::Dispatcher 3 | 4 | private 5 | 6 | # Yields an AMQP channel to the block, doing any error handling or synchronization 7 | # necessary. 8 | def run(&blk) 9 | raise NotImplementedError 10 | end 11 | 12 | def deliver_job(job) 13 | run do |client| 14 | exchange = client.exchange(job.exchange_name, :type => :headers) 15 | publish_job(job, exchange) 16 | end 17 | end 18 | 19 | def deliver_job_update(job, data) 20 | run do |client| 21 | exchange = client.exchange("woodhouse.progress", :type => :direct) 22 | client.queue(job.job_id, :arguments => {"x-expires" => 5*60*1000}).bind(exchange, :routing_key => job.job_id) 23 | exchange.publish(data.to_json, :routing_key => job.job_id) 24 | end 25 | end 26 | 27 | end 28 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/test_dispatcher.rb: -------------------------------------------------------------------------------- 1 | # A dispatcher which simply swallows and stores jobs without performing them. This 2 | # is to be used in testing other applications' interactions with Woodhouse. 3 | class Woodhouse::Dispatchers::TestDispatcher < Woodhouse::Dispatcher 4 | 5 | # All jobs (Woodhouse::Job) which have been dispatched since this dispatcher was last cleared. 6 | attr_reader :jobs 7 | # All job updates (used in the Progress extension) which have been dispatched since this dispatcher was last cleared. 8 | attr_reader :job_updates 9 | 10 | # Wipe out all stored jobs and job updates. 11 | def clear! 12 | jobs.clear 13 | job_updates.clear 14 | end 15 | 16 | private 17 | 18 | def after_initialize(*) 19 | @jobs = [] 20 | @job_updates = [] 21 | end 22 | 23 | def deliver_job(job) 24 | @jobs << job 25 | end 26 | 27 | def deliver_job_update(job, data) 28 | @job_updates << [job, data] 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'rake' 3 | require 'bundler/setup' 4 | require 'bundler' 5 | Bundler::GemHelper.install_tasks 6 | 7 | require 'spec/rake/spectask' 8 | namespace :spec do 9 | 10 | Spec::Rake::SpecTask.new(:server) do |t| 11 | t.spec_files = FileList["spec/**/*_spec.rb"] - FileList["spec/integration/*_spec.rb"] 12 | end 13 | 14 | Spec::Rake::SpecTask.new(:client) do |t| 15 | t.spec_files = %w[spec/layout_spec.rb spec/middleware_stack_spec.rb spec/mixin_registry_spec.rb] 16 | end 17 | 18 | end 19 | 20 | # Full server specs are supported on Ruby 1.9 or JRuby. 21 | if RUBY_VERSION.to_f >= 1.9 or %w[jruby rbx].include?(RUBY_ENGINE) 22 | task :spec => "spec:server" 23 | else 24 | task :spec => "spec:client" 25 | end 26 | 27 | task :default => :spec 28 | 29 | if ENV['RDOC'] 30 | require 'rdoc/task' 31 | Rake::RDocTask.new(:rdoc) do |t| 32 | t.main = "README.rdoc" 33 | t.rdoc_files.include("README.rdoc", "lib/**/*.rb") 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatcher.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Dispatcher 2 | 3 | def initialize(config, opts = {}, &blk) 4 | @config = config 5 | after_initialize(config, opts = {}, &blk) 6 | end 7 | 8 | def dispatch(class_name, job_method, arguments) 9 | dispatch_job Woodhouse::Job.new(class_name, job_method, arguments) 10 | end 11 | 12 | def dispatch_job(job) 13 | @config.dispatcher_middleware.call(job) {|job| 14 | deliver_job(job) 15 | } 16 | job 17 | end 18 | 19 | def update_job(job, data = {}) 20 | deliver_job_update(job, data) 21 | end 22 | 23 | private 24 | 25 | def after_initialize(config, opts = {}, &blk) 26 | 27 | end 28 | 29 | def deliver_job(job) 30 | raise NotImplementedError, "implement #deliver_job in a subclass of Woodhouse::Dispatcher" 31 | end 32 | 33 | def deliver_job_update(job, data) 34 | raise NotImplementedError, "implement #deliver_job_update in a subclass of Woodhouse::Dispatcher" 35 | end 36 | 37 | end 38 | -------------------------------------------------------------------------------- /spec/integration/bunny_worker_process_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/../shared_contexts' 3 | 4 | describe Woodhouse::Runners::BunnyRunner do 5 | it_should_behave_like "common" 6 | 7 | let(:scheduler) { 8 | common_config.runner_type = :bunny 9 | Woodhouse::Scheduler.new(common_config) 10 | } 11 | 12 | let(:worker) { 13 | Woodhouse::Layout::Worker.new(:FooBarWorker, :foo, :only => { :orz => "*happy campers*" }) 14 | } 15 | 16 | it "should pull jobs off a queue" do 17 | scheduler.start_worker worker 18 | sleep 0.5 19 | # TODO: this should use the bunny dispatcher, once I write it 20 | bunny = Bunny.new 21 | bunny.start 22 | exchange = bunny.exchange(worker.exchange_name, :type => :headers) 23 | exchange.publish("hi", :headers => { :orz => "*happy campers*" }) 24 | exchange.publish("hi", :headers => { :orz => "*silly cows*" }) 25 | bunny.stop 26 | sleep 0.2 27 | FakeWorker.jobs.should_not be_empty 28 | FakeWorker.jobs.last[:orz].should == "*happy campers*" 29 | scheduler.spin_down 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 play/type GmbH 2 | Copyright (c) 2012 CrowdCompass, Inc. 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining 5 | a copy of this software and associated documentation files (the 6 | "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, 8 | distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so, subject to 10 | the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 17 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 19 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 20 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 21 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/bunny_dispatcher.rb: -------------------------------------------------------------------------------- 1 | require 'bunny' 2 | require 'connection_pool' 3 | require 'woodhouse/dispatchers/common_amqp_dispatcher' 4 | 5 | class Woodhouse::Dispatchers::BunnyDispatcher < Woodhouse::Dispatchers::CommonAmqpDispatcher 6 | 7 | def initialize(config, opts = {}, &blk) 8 | super 9 | @pool = new_pool 10 | end 11 | 12 | private 13 | 14 | def publish_job(job, exchange) 15 | exchange.publish(job.payload, :headers => job.arguments) 16 | end 17 | 18 | def run 19 | retried = false 20 | @pool.with do |conn| 21 | yield conn 22 | end 23 | rescue Bunny::ClientTimeout => err 24 | if retried 25 | raise Woodhouse::ConnectionError, "timed out while contacting AMQP server: #{err.message}" 26 | else 27 | new_pool! 28 | retried = true 29 | retry 30 | end 31 | end 32 | 33 | private 34 | 35 | def new_pool! 36 | @pool = new_pool 37 | end 38 | 39 | def new_pool 40 | @bunny.stop if @bunny 41 | 42 | bunny = @bunny = Bunny.new(@config.server_info || {}) 43 | @bunny.start 44 | 45 | ConnectionPool.new { bunny.create_channel } 46 | rescue Bunny::TCPConnectionFailed => err 47 | raise Woodhouse::ConnectionError, "unable to connect to AMQP server: #{err.message}" 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/hot_bunnies_dispatcher.rb: -------------------------------------------------------------------------------- 1 | # 2 | # A Dispatcher implementation that uses hot_bunnies, a JRuby AMQP client using the 3 | # Java client for RabbitMQ. This class can be loaded if hot_bunnies is not 4 | # available, but it will fail upon initialization. If you want to use this 5 | # runner (it's currently the only one that works very well), make sure to 6 | # add 7 | # 8 | # gem 'hot_bunnies' 9 | # 10 | # to your Gemfile. 11 | # 12 | 13 | require 'woodhouse/dispatchers/common_amqp_dispatcher' 14 | 15 | class Woodhouse::Dispatchers::HotBunniesDispatcher < Woodhouse::Dispatchers::CommonAmqpDispatcher 16 | 17 | begin 18 | require 'hot_bunnies' 19 | rescue LoadError => err 20 | define_method(:initialize) {|*args| 21 | raise err 22 | } 23 | else 24 | def initialize(config, opts = {}, &blk) 25 | super 26 | new_connection 27 | @mutex = Mutex.new 28 | end 29 | end 30 | 31 | private 32 | 33 | def run 34 | @mutex.synchronize do 35 | yield @channel 36 | end 37 | end 38 | 39 | def publish_job(job, exchange) 40 | exchange.publish(job.payload, :properties => { :headers => job.arguments }) 41 | end 42 | 43 | def new_connection 44 | @connection = HotBunnies.connect(@config.server_info) 45 | @channel = @connection.create_channel 46 | end 47 | 48 | end 49 | -------------------------------------------------------------------------------- /spec/progress_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse/extensions/progress' 2 | 3 | describe Woodhouse::Progress do 4 | 5 | describe "JobWithProgress" do 6 | subject { Object.new.tap do |obj| obj.extend Woodhouse::Progress::JobWithProgress end } 7 | 8 | it "should provide a method for creating a StatusTicker" do 9 | subject.status_ticker("orz").should be_kind_of(Woodhouse::Progress::StatusTicker) 10 | end 11 | 12 | end 13 | 14 | describe "StatusTicker" do 15 | let(:sink) { double("progress sink") } 16 | let(:job) { 17 | Object.new.tap do |obj| 18 | obj.extend Woodhouse::Progress::JobWithProgress 19 | obj.progress_sink = sink 20 | end 21 | } 22 | 23 | it "should take initial status and tick arguments" do 24 | ticker = job.status_ticker("orz", :top => 100, :start => 10, :status => "working") 25 | ticker.to_hash.should == { "orz" => { "top" => 100, "current" => 10, "status" => "working" } } 26 | end 27 | 28 | context "#tick" do 29 | 30 | it "should send progress updates" do 31 | pending "fix for async" 32 | ticker = job.status_ticker("orz") 33 | sink.should_receive(:update_job).with(job, { "orz" => { "status" => "funky", "current" => 1 } }) 34 | ticker.tick(:status => "funky") 35 | end 36 | 37 | end 38 | 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/generators/woodhouse_generator.rb: -------------------------------------------------------------------------------- 1 | class WoodhouseGenerator < Rails::Generators::Base 2 | desc "Creates initializer and script files for Woodhouse." 3 | 4 | def create_initializer 5 | initializer "woodhouse.rb", <<-EOF 6 | Woodhouse.configure do |woodhouse| 7 | # woodhouse.dispatcher_type = :amqp 8 | # woodhouse.extension :progress 9 | # woodhouse.extension :new_relic 10 | end 11 | 12 | Woodhouse.layout do |layout| 13 | layout.node(:default) do |node| 14 | node.all_workers 15 | end 16 | end 17 | EOF 18 | end 19 | 20 | def create_script 21 | create_file "script/woodhouse", <<-EOF 22 | #!/usr/bin/env ruby 23 | require File.expand_path(File.dirname(__FILE__) + '/../config/environment') 24 | 25 | logg = Logger.new(File.dirname(__FILE__) + "/../log/woodhouse.log") 26 | logg.level = Logger::DEBUG 27 | logg.formatter = Logger::Formatter.new 28 | 29 | Celluloid.logger = logg 30 | Woodhouse.global_configuration.logger = logg 31 | 32 | Woodhouse.global_configuration.dispatcher_type = :amqp 33 | 34 | Woodhouse::Process.new.execute 35 | EOF 36 | end 37 | 38 | def create_config 39 | create_file "config/woodhouse.yml", <<-EOF 40 | development: 41 | dispatcher_type: local 42 | test: 43 | dispatcher_type: local 44 | production: 45 | dispatcher_type: amqp 46 | server_info: 47 | host: localhost 48 | EOF 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /spec/scheduler_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::Scheduler do 5 | it_should_behave_like "common" 6 | 7 | subject { Woodhouse::Scheduler.new(common_config) } 8 | 9 | let(:worker) { 10 | Woodhouse::Layout::Worker.new(:FooBarWorker, :foo) 11 | } 12 | 13 | let(:worker_2) { 14 | Woodhouse::Layout::Worker.new(:FooBarWorker, :foo, :only => { :job => "big" }) 15 | } 16 | 17 | it "should create a new worker set when a new worker is sent to #start_worker" do 18 | subject.start_worker worker 19 | subject.should be_running_worker(worker) 20 | end 21 | 22 | it "should not create a new worker set when an existing worker is sent to #start_worker" do 23 | subject.start_worker(worker).should be_true 24 | subject.start_worker(worker).should be_false 25 | end 26 | 27 | it "should spin down and remove a worker set when a worker is sent to #stop_worker" do 28 | subject.start_worker worker 29 | subject.stop_worker worker, true 30 | subject.should_not be_running_worker(worker) 31 | end 32 | 33 | it "should spin down and remove all worker sets when #spin_down is called" do 34 | subject.start_worker worker 35 | subject.start_worker worker_2 36 | subject.spin_down 37 | subject.should_not be_running_worker(worker) 38 | subject.should_not be_running_worker(worker_2) 39 | end 40 | 41 | end 42 | -------------------------------------------------------------------------------- /lib/woodhouse/queue_criteria.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse 2 | 3 | class QueueCriteria 4 | attr_reader :criteria 5 | attr_accessor :exclusive 6 | 7 | def initialize(values = {}, flags = nil) 8 | flags ||= {} 9 | self.exclusive ||= flags[:exclusive] 10 | if values.kind_of?(self.class) 11 | values = values.criteria 12 | end 13 | unless values.nil? 14 | @criteria = stringify_values(values).freeze 15 | end 16 | end 17 | 18 | def ==(other) 19 | @criteria == other.criteria 20 | end 21 | 22 | def describe 23 | @criteria.inspect 24 | end 25 | 26 | def amqp_headers 27 | # TODO: needs to be smarter 28 | @criteria ? @criteria.merge('x-match' => 'all') : {} 29 | end 30 | 31 | def queue_key 32 | @criteria ? @criteria.map{|k,v| 33 | "#{k.downcase}_#{v.downcase}" 34 | }.join("_") : "" 35 | end 36 | 37 | def matches?(args) 38 | return true if @criteria.nil? 39 | return false if exclusive? and @criteria.length != args.keys.reject{|k| k =~ /^_/ }.length 40 | 41 | @criteria.all? do |key, val| 42 | args[key] == val 43 | end 44 | end 45 | 46 | def exclusive? 47 | !!exclusive 48 | end 49 | 50 | private 51 | 52 | def stringify_values(hash) 53 | hash.inject({}) {|h,(k,v)| 54 | h[k.to_s] = v.to_s 55 | h 56 | } 57 | end 58 | 59 | end 60 | 61 | end 62 | -------------------------------------------------------------------------------- /lib/woodhouse/process.rb: -------------------------------------------------------------------------------- 1 | # TODO: take arguments. Also consider using thor. 2 | class Woodhouse::Process 3 | 4 | def initialize(keyw = {}) 5 | @server = keyw[:server] || build_default_server(keyw) 6 | self.class.register_instance self 7 | end 8 | 9 | def self.register_instance(instance) 10 | @instance = instance 11 | end 12 | 13 | # Returns the current global Woodhouse process instance, if it is running. 14 | def self.instance 15 | @instance 16 | end 17 | 18 | def execute 19 | # Borrowed this from sidekiq. https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/cli.rb 20 | trap "INT" do 21 | Thread.main.raise Interrupt 22 | end 23 | 24 | trap "TERM" do 25 | Thread.main.raise Interrupt 26 | end 27 | 28 | Woodhouse::Watchdog.start 29 | 30 | begin 31 | @server.async.start 32 | puts "Woodhouse serving as of #{Time.now}. Ctrl-C to stop." 33 | @server.wait(:shutdown) 34 | rescue Interrupt 35 | shutdown 36 | ensure 37 | @server.terminate 38 | Woodhouse::Watchdog.stop 39 | end 40 | end 41 | 42 | def shutdown 43 | puts "Shutting down." 44 | @server.async.shutdown 45 | @server.wait(:shutdown) 46 | end 47 | 48 | private 49 | 50 | def build_default_server(keyw) 51 | Woodhouse::Server.new( 52 | :layout => keyw[:layout] || Woodhouse.global_layout, 53 | :node => keyw[:node] || :default 54 | ) 55 | end 56 | 57 | end 58 | -------------------------------------------------------------------------------- /lib/woodhouse/rails.rb: -------------------------------------------------------------------------------- 1 | if defined?(Rails::Railtie) 2 | module Woodhouse::RailsExtensions 3 | def layout(&blk) 4 | unless @delay_finished 5 | @delayed_layout = blk 6 | else 7 | super 8 | end 9 | end 10 | 11 | def finish_loading_layout! 12 | @delay_finished = true 13 | if @delayed_layout 14 | layout &@delayed_layout 15 | end 16 | end 17 | end 18 | 19 | Woodhouse.extend Woodhouse::RailsExtensions 20 | 21 | class Woodhouse::Rails < Rails::Engine 22 | initializer 'woodhouse-defaults', before: :load_config_initializers do 23 | # Legacy config file just containing AMQP information. 24 | legacy_config_path = Rails.root.join("config/workling.yml") 25 | # New config file containing any configuration options. 26 | config_path = Rails.root.join("config/woodhouse.yml") 27 | 28 | # Preload everything in app/workers so default layout includes them 29 | Rails.root.join("app/workers").tap do |workers| 30 | Pathname.glob(workers.join("**/*.rb")).each do |worker_path| 31 | worker_path.relative_path_from(workers).basename(".rb").to_s.camelize.constantize 32 | end 33 | end 34 | 35 | # Set up reasonable defaults 36 | Woodhouse.configure do |config| 37 | config.logger = ::Rails.logger 38 | 39 | config.load_yaml legacy_config_path, section: "server_info", environment: ::Rails.env 40 | config.load_yaml config_path, environment: ::Rails.env 41 | end 42 | end 43 | 44 | initializer "woodhouse-layout" do 45 | Woodhouse.finish_loading_layout! 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/middleware_stack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::MiddlewareStack do 5 | it_should_behave_like "common" 6 | 7 | subject { Woodhouse::MiddlewareStack.new(common_config) } 8 | let(:dummy) { MiddlewareDummy.new } 9 | 10 | class MiddlewareDummy 11 | attr_reader :was_called, :sent_item 12 | def initialize 13 | @was_called = false 14 | @sent_item = nil 15 | end 16 | 17 | def call(job) 18 | @was_called = true 19 | @sent_item = job 20 | yield job 21 | end 22 | 23 | end 24 | 25 | it "should work if empty" do 26 | called = :not_called 27 | subject.call("LANAAAA!") {|object| 28 | object.should == "LANAAAA!" 29 | called = :called 30 | } 31 | called.should == :called 32 | end 33 | 34 | it "should send #call to stack items which respond to that" do 35 | subject << dummy 36 | subject.call("is it not?") {|object| } 37 | dummy.was_called.should be_true 38 | dummy.sent_item.should == "is it not?" 39 | end 40 | 41 | it "should send #new to stack items which respond to that" do 42 | fake_class = stub('mware item', :new => dummy) 43 | subject << fake_class 44 | subject.call("danger zone") {|object| } 45 | dummy.was_called.should be_true 46 | dummy.sent_item.should == "danger zone" 47 | end 48 | 49 | it "should complain with ArgumentError if entries respond to neither #call nor #new" do 50 | subject << nil 51 | expect do 52 | subject.call("danger zone") {|object| } 53 | end.to raise_error(ArgumentError) 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/woodhouse/layout_builder.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::LayoutBuilder 2 | 3 | attr_reader :layout 4 | 5 | class NodeBuilder 6 | 7 | def initialize(config, node) 8 | @config = config 9 | @node = node 10 | end 11 | 12 | def all_workers(options = {}) 13 | @node.default_configuration! @config, options 14 | end 15 | 16 | def add(class_name, job_method, opts = {}) 17 | if job_method.kind_of?(Hash) 18 | # Two-argument invocation 19 | opts = job_method 20 | job_method = nil 21 | methods = @config.registry[class_name].public_instance_methods(false) 22 | else 23 | methods = [job_method] 24 | end 25 | remove(class_name, job_method, opts.empty? ? nil : opts) 26 | methods.each do |method| 27 | @node.add_worker Woodhouse::Layout::Worker.new(class_name, method, opts) 28 | end 29 | end 30 | 31 | def remove(class_name, job_method = nil, opts = nil) 32 | @node.workers.select{|worker| 33 | worker.worker_class_name == class_name && 34 | (job_method.nil? || worker.job_method == job_method) && 35 | (opts.nil? || worker.criteria.criteria == opts[:only]) 36 | }.each do |worker| 37 | @node.remove_worker(worker) 38 | end 39 | end 40 | 41 | end 42 | 43 | def initialize(config, layout = nil) 44 | @config = config 45 | @layout = layout || Woodhouse::Layout.new 46 | @nodes ||= {} 47 | yield self if block_given? 48 | end 49 | 50 | def node(name) 51 | @layout.node(name) || @layout.add_node(name) 52 | yield(@nodes[name] ||= NodeBuilder.new(@config, @layout.node(name))) 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /lib/woodhouse/job.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'forwardable' 3 | 4 | class Woodhouse::Job 5 | attr_accessor :worker_class_name, :job_method, :arguments, :payload 6 | extend Forwardable 7 | 8 | def_delegators :arguments, :each 9 | 10 | def initialize(class_name = nil, method = nil, args = nil) 11 | self.worker_class_name = class_name 12 | self.job_method = method 13 | self.arguments = args 14 | unless arguments["_id"] 15 | arguments["_id"] = generate_id 16 | end 17 | if arguments["payload"] 18 | self.payload = arguments.delete("payload") 19 | end 20 | yield self if block_given? 21 | end 22 | 23 | def job_id 24 | arguments["_id"] 25 | end 26 | 27 | def to_hash 28 | { 29 | "worker_class_name" => worker_class_name, 30 | "job_method" => job_method, 31 | }.merge(arguments) 32 | end 33 | 34 | def job_method=(value) 35 | @job_method = value ? value.to_sym : nil 36 | end 37 | 38 | def arguments=(h) 39 | @arguments = (h || {}).inject({}){|args,(k,v)| 40 | args[k.to_s] = v.to_s 41 | args 42 | } 43 | end 44 | 45 | def [](key) 46 | arguments[key.to_s] 47 | end 48 | 49 | def maybe(meth, *args, &blk) 50 | if respond_to?(meth) 51 | send(meth, *args, &blk) 52 | end 53 | end 54 | 55 | # TODO: copypasted from Woodhouse::Layout::Worker. Fix that 56 | def exchange_name 57 | "#{worker_class_name}_#{job_method}".downcase 58 | end 59 | 60 | def queue_name 61 | exchange_name 62 | end 63 | 64 | def describe 65 | "#{worker_class_name}##{job_method}(#{arguments.inspect})" 66 | end 67 | 68 | def generate_id 69 | SecureRandom.hex(16) 70 | end 71 | 72 | def payload 73 | @payload || " " 74 | end 75 | 76 | end 77 | -------------------------------------------------------------------------------- /lib/woodhouse/dispatchers/file_dispatcher.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | class Woodhouse::Dispatchers::FileDispatcher < Woodhouse::Dispatcher 4 | attr_accessor :jobs_dir, :queue_dir 5 | 6 | DEFAULT_QUEUE_DIR = '/tmp/woodhouse/queue' 7 | 8 | def initialize(config, opts = {}, &blk) 9 | super 10 | 11 | server_info = @config.server_info || {} 12 | self.queue_dir = server_info[:path] || DEFAULT_QUEUE_DIR 13 | self.jobs_dir = "#{queue_dir}/jobs" 14 | 15 | unless File.directory?(jobs_dir) # subdirectory of queue_dir 16 | @config.logger.debug "[Woodhouse initialize] Creating queue directory '#{queue_dir}'" 17 | FileUtils.mkdir_p jobs_dir 18 | end 19 | end 20 | 21 | 22 | private 23 | 24 | def deliver_job(job) 25 | filename = "#{jobs_dir}/#{job.job_id}" 26 | payload = YAML.dump(job) 27 | 28 | @config.logger.debug "[Woodhouse] Writing job #{job.exchange_name} to #{filename}" 29 | File.open(filename, 'w') {|f| f.write(YAML.dump(job)) } 30 | 31 | enqueue(filename) 32 | end 33 | 34 | def deliver_job_update(job, data) 35 | @config.logger.info "[Woodhouse job update] #{job.job_id} -- #{data.inspect}" 36 | end 37 | 38 | def enqueue(job_filename) 39 | enqueued_filename = Dir["#{queue_dir}/j-*"].max || "#{queue_dir}/j-00000000" 40 | 10.times do 41 | begin 42 | enqueued_filename.succ! 43 | File.symlink(job_filename, enqueued_filename) 44 | break 45 | rescue Errno::EEXIST 46 | # Another dispatcher beat us to this position, try again 47 | end 48 | 49 | raise "Woodhouse FileDispatcher is not designed for high load scenarios. " + 50 | "Maybe you should be using the AMQP dispatcher instead?" 51 | end 52 | 53 | enqueued_filename 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /lib/woodhouse/job_execution.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::JobExecution 2 | 3 | class << self 4 | attr_accessor :fatal_error_proc 5 | end 6 | 7 | memory_error_rx = /((OutOf|NoMemory)Error|Java heap space)/ 8 | self.fatal_error_proc = lambda do |err| 9 | err.class.name =~ memory_error_rx or err.message =~ memory_error_rx 10 | end 11 | 12 | def initialize(config, job) 13 | @config = config 14 | @job = job 15 | end 16 | 17 | # Looks up the correct worker class for a job and executes it, running it 18 | # through the runner middleware stack first. Returns true if the job finishes 19 | # without an exception, false otherwise. 20 | # 21 | # If you need to keep track of exceptions raised by jobs, add middleware to 22 | # handle them, like Woodhouse::Middleware::AirbrakeExceptions. 23 | def execute 24 | worker = @config.registry[@job.worker_class_name] 25 | unless worker 26 | raise Woodhouse::WorkerNotFoundError, "couldn't find job class #{@job.worker_class_name}" 27 | end 28 | work_object = worker.new 29 | begin 30 | @config.runner_middleware.call(@job, work_object) {|job, work_object| 31 | work_object.send(job.job_method, job) 32 | } 33 | return true 34 | rescue Woodhouse::FatalError 35 | raise 36 | rescue => err 37 | if fatal_error?(err) 38 | raise err 39 | else 40 | # Ignore the exception 41 | return false 42 | end 43 | end 44 | end 45 | 46 | private 47 | 48 | # TODO: lots of similar methods scattered around. Should refactor. 49 | def symbolize_keys(hash) 50 | hash.inject({}) {|h,(k,v)| 51 | h[k.to_sym] = v 52 | h 53 | } 54 | end 55 | 56 | def fatal_error?(err) 57 | self.class.fatal_error_proc.call(err) 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/woodhouse/layout_serializer.rb: -------------------------------------------------------------------------------- 1 | require 'json' 2 | 3 | class Woodhouse::LayoutSerializer 4 | 5 | def initialize(layout) 6 | @layout = layout 7 | end 8 | 9 | def as_hash 10 | { 11 | :nodes => node_list(layout.nodes) 12 | } 13 | end 14 | 15 | def to_json 16 | as_hash.to_json 17 | end 18 | 19 | def self.dump(layout) 20 | new(layout).to_json 21 | end 22 | 23 | def self.load(json) 24 | LayoutLoader.new(json).layout 25 | end 26 | 27 | class LayoutLoader 28 | 29 | def initialize(json) 30 | @entries = JSON.parse(json) 31 | end 32 | 33 | def layout 34 | Woodhouse::Layout.new.tap do |layout| 35 | @entries['nodes'].each do |node| 36 | new_node = layout.add_node(node['name']) 37 | node['workers'].each do |worker| 38 | new_node.add_worker Woodhouse::Layout::Worker.new(worker['worker_class_name'], worker['job_method'], :threads => worker['threads'], :only => worker['criteria']) 39 | end 40 | end 41 | end 42 | end 43 | 44 | end 45 | 46 | private 47 | 48 | attr_reader :layout 49 | 50 | def node_list(nodes) 51 | nodes.map{|node| 52 | node_hash(node) 53 | } 54 | end 55 | 56 | def node_hash(node) 57 | { 58 | :name => node.name, 59 | :workers => worker_list(node.workers) 60 | } 61 | end 62 | 63 | def worker_list(workers) 64 | workers.map{|worker| 65 | worker_hash(worker) 66 | } 67 | end 68 | 69 | def worker_hash(worker) 70 | { 71 | :worker_class_name => worker.worker_class_name, 72 | :job_method => worker.job_method, 73 | :threads => worker.threads, 74 | :criteria => criteria_hash(worker.criteria) 75 | } 76 | end 77 | 78 | def criteria_hash(criteria) 79 | criteria.criteria 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /woodhouse.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "woodhouse/version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "woodhouse" 7 | s.version = Woodhouse::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Matthew Boeh"] 10 | s.email = ["matt@crowdcompass.com", "matthew.boeh@gmail.com"] 11 | s.licenses = ["MIT"] 12 | s.homepage = "http://github.com/mboeh/woodhouse" 13 | s.summary = %q{An AMQP-based background worker system for Ruby} 14 | s.description = %q{An AMQP-based background worker system for Ruby designed to make managing heterogenous tasks relatively easy. 15 | 16 | The use case for Woodhouse is for reliable and sane performance in situations where jobs on a single queue may vary significantly in length. The goal is to permit large numbers of quick jobs to be serviced even when many slow jobs are in the queue. A secondary goal is to provide a sane way for jobs on a given queue to be given special priority or dispatched to a server more suited to them. 17 | 18 | Clients (i.e., your application) may be using either Ruby 1.9 in any VM.} 19 | 20 | s.rubyforge_project = "woodhouse" 21 | 22 | s.add_dependency 'celluloid', '~> 0.15' 23 | s.add_dependency 'bunny', "~> 0.9.8" 24 | s.add_dependency 'connection_pool', '~> 2.0' 25 | s.add_dependency 'json', '~> 1.8' 26 | s.add_dependency 'cause', '~> 0.1' 27 | 28 | s.add_development_dependency 'rspec', '~> 1.3.1' 29 | s.add_development_dependency 'rake' 30 | s.add_development_dependency 'guard' 31 | s.add_development_dependency 'guard-rspec' 32 | s.add_development_dependency 'mocha' 33 | 34 | s.files = `git ls-files`.split("\n") 35 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 36 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 37 | s.require_paths = ["lib"] 38 | end 39 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Woodhouse 2 | 3 | [Build Status](http://travis-ci.org/mboeh/woodhouse) 4 | 5 | A RabbitMQ-based background worker system for Ruby designed to make managing heterogenous tasks relatively easy. 6 | 7 | The use case for Woodhouse is for reliable and sane performance in situations where jobs on a single queue may vary significantly 8 | in length. The goal is to permit large numbers of quick jobs to be serviced even when many slow jobs are in the queue. A secondary 9 | goal is to provide a sane way for jobs on a given queue to be given special priority or dispatched to a server more suited to them. 10 | 11 | Woodhouse 1.0, located in the 1-0-stable branch, is production-ready and stable for Ruby 1.9. The master branch includes development 12 | on Woodhouse 2.0, which targets Ruby 2.0 or later. 13 | 14 | Please look at the [wiki](https://github.com/mboeh/woodhouse/wiki) for documentation. 15 | 16 | ## Features 17 | 18 | * Configurable worker sets per server 19 | * Configurable number of threads per worker 20 | * Segmenting a single queue among multiple workers based on job characteristics (using AMQP header exchanges) 21 | * Extension system 22 | * Progress reporting on jobs with the `progress` extension 23 | * New Relic background job reporting with the `new_relic` extension 24 | * Live status reporting with the `status` extension 25 | * Job dispatch and execution middleware stacks 26 | 27 | ## Upcoming 28 | 29 | * Live reconfiguration of workers -- add or remove workers across one or more nodes without restarting 30 | * Persistent configuration changes -- configuration changes saved to a data store and kept across deploys 31 | * Web interface 32 | 33 | ## Acknowledgements 34 | 35 | Woodhouse originated in a substantially modified version of the Workling background worker system, although all code has since 36 | been replaced. 37 | 38 | This library was developed for [CrowdCompass](http://crowdcompass.com) and was released as open source with their permission. 39 | -------------------------------------------------------------------------------- /lib/woodhouse/runners/bunny_runner.rb: -------------------------------------------------------------------------------- 1 | require 'bunny' 2 | 3 | class Woodhouse::Runners::BunnyRunner < Woodhouse::Runner 4 | include Celluloid 5 | 6 | def subscribe 7 | bunny = Bunny.new(@config.server_info) 8 | bunny.start 9 | channel = bunny.create_channel 10 | channel.prefetch(1) 11 | queue = channel.queue(@worker.queue_name) 12 | exchange = channel.exchange(@worker.exchange_name, :type => :headers) 13 | queue.bind(exchange, :arguments => @worker.criteria.amqp_headers) 14 | worker = Celluloid.current_actor 15 | queue.subscribe(:ack => true, :block => false) do |delivery, props, payload| 16 | begin 17 | job = make_job(props, payload) 18 | if can_service_job?(job) 19 | if service_job(job) 20 | channel.acknowledge(delivery.delivery_tag, false) 21 | else 22 | channel.reject(delivery.delivery_tag, false) 23 | end 24 | else 25 | @config.logger.error("Cannot service job #{job.describe} in queue for #{@worker.describe}") 26 | channel.reject(delivery.delivery_tag, false) 27 | end 28 | rescue => err 29 | begin 30 | @config.logger.error("Error bubbled up out of worker. This shouldn't happen. #{err.message}") 31 | err.backtrace.each do |btr| 32 | @config.logger.error(" #{btr}") 33 | end 34 | # Don't risk grabbing this job again. 35 | channel.reject(delivery.delivery_tag, false) 36 | ensure 37 | worker.bail_out(err) 38 | end 39 | end 40 | end 41 | wait :spin_down 42 | end 43 | 44 | def bail_out(err) 45 | raise Woodhouse::BailOut, "#{err.class}: #{err.message}" 46 | end 47 | 48 | def spin_down 49 | signal :spin_down 50 | end 51 | 52 | def make_job(properties, payload) 53 | Woodhouse::Job.new(@worker.worker_class_name, @worker.job_method) do |job| 54 | args = properties.headers 55 | job.arguments = args 56 | job.payload = payload 57 | end 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /lib/woodhouse/server.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse 2 | 3 | class Server 4 | include Celluloid 5 | include Woodhouse::Util 6 | 7 | attr_reader :layout, :node 8 | attr_accessor :configuration 9 | 10 | trap_exit :scheduler_died 11 | 12 | def initialize(keyw = {}) 13 | self.layout = keyw[:layout] 14 | self.node = keyw[:node] 15 | self.configuration = keyw[:configuration] || Woodhouse.global_configuration 16 | end 17 | 18 | def layout=(value) 19 | expect_arg_or_nil :value, Woodhouse::Layout, value 20 | @previous_layout = @layout 21 | @layout = value ? value.frozen_clone : nil 22 | end 23 | 24 | def node=(value) 25 | @node = value || :default 26 | end 27 | 28 | def start 29 | # TODO: don't pass global config 30 | @scheduler ||= Woodhouse::Scheduler.new_link(configuration) 31 | return false unless ready_to_start? 32 | configuration.triggers.trigger :server_start 33 | dispatch_layout_changes 34 | true 35 | end 36 | 37 | def reload 38 | dispatch_layout_changes! 39 | end 40 | 41 | def ready_to_start? 42 | @node and @layout and @layout.node(@node) 43 | end 44 | 45 | # TODO: do this better 46 | def shutdown 47 | @scheduler.spin_down 48 | @scheduler.terminate 49 | configuration.triggers.trigger :server_end 50 | signal :shutdown 51 | end 52 | 53 | private 54 | 55 | def scheduler_died(actor, reason) 56 | signal :shutdown 57 | end 58 | 59 | def dispatch_layout_changes 60 | if @layout.nil? 61 | shutdown 62 | else 63 | apply_layout_changes @layout.changes_from(@previous_layout, @node) 64 | end 65 | end 66 | 67 | def apply_layout_changes(changes) 68 | if @scheduler 69 | changes.adds.each do |add| 70 | @scheduler.start_worker(add) 71 | end 72 | changes.drops.each do |drop| 73 | @scheduler.stop_worker(drop) 74 | end 75 | end 76 | end 77 | 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /spec/layout_builder_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::LayoutBuilder do 5 | it_should_behave_like "common" 6 | 7 | it "should provide a DSL to set up layouts" do 8 | registry = { 9 | :Pam => FakeWorker, 10 | :Cyril => FakeWorker, 11 | :Ray => FakeWorker, 12 | :Lana => FakeWorker, 13 | } 14 | common_config.registry = registry 15 | builder = Woodhouse::LayoutBuilder.new(common_config) do |layout| 16 | layout.node(:default) do |default| 17 | # Eight workers... 18 | default.all_workers :threads => 2 19 | # Six workers... 20 | default.remove :Cyril 21 | # Five workers... 22 | default.remove :Ray, :foo 23 | # Six workers. 24 | default.add :Ray, :bar, :only => { :baz => "bat" }, :exclusive => true 25 | end 26 | layout.node(:odin) do |odin| 27 | # Two workers. 28 | odin.add :Lana, :threads => 2 29 | # Still two workers 30 | odin.add :Lana, :threads => 5 31 | end 32 | end 33 | layout = builder.layout 34 | layout.nodes.should have(2).nodes 35 | default = layout.node(:default) 36 | default.workers.should have(6).workers 37 | default.workers.first.threads.should == 2 38 | default.workers.map(&:worker_class_name).should_not include(:Cyril) 39 | default.workers.map(&:worker_class_name).should include(:Ray) 40 | default.workers.select{|wk| 41 | wk.worker_class_name == :Ray 42 | }.map(&:job_method).should_not include(:foo) 43 | ray = default.workers.detect{|wk| 44 | wk.worker_class_name == :Ray && wk.criteria.criteria 45 | } 46 | ray.should_not be_nil 47 | ray.criteria.matches?("baz" => "bat").should be_true 48 | ray.criteria.should be_exclusive 49 | odin = layout.node(:odin) 50 | odin.workers.should have(2).workers 51 | odin.workers.first.threads.should == 5 52 | 53 | Woodhouse::Layout.load(layout.dump).dump.should == layout.dump 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /spec/shared_contexts.rb: -------------------------------------------------------------------------------- 1 | #Celluloid.logger = nil 2 | 3 | class FakeWorker 4 | 5 | class << self 6 | attr_accessor :last_worker 7 | attr_accessor :jobs 8 | 9 | def available_jobs 10 | [:foo, :bar] 11 | end 12 | end 13 | 14 | self.jobs ||= [] 15 | 16 | def initialize 17 | FakeWorker.last_worker = self 18 | FakeWorker.jobs ||= [] 19 | end 20 | 21 | def foo(args) 22 | FakeWorker.jobs << args 23 | end 24 | 25 | def bar(args) 26 | FakeWorker.jobs << args 27 | end 28 | 29 | end 30 | 31 | Woodhouse.configure do |config| 32 | config.registry = { :FooBarWorker => FakeWorker } 33 | config.runner_type = :dummy 34 | config.dispatcher_type = :local 35 | config.logger = Logger.new("/dev/null") 36 | end 37 | 38 | shared_examples_for "common" do 39 | 40 | let(:empty_layout) { 41 | Woodhouse::Layout.new 42 | } 43 | 44 | let(:populated_layout) { 45 | Woodhouse::Layout.new.tap do |layout| 46 | layout.add_node Woodhouse::Layout::Node.new(:default) 47 | layout.node(:default).tap do |default| 48 | default.add_worker Woodhouse::Layout::Worker.new(:FooWorker, :foo) 49 | default.add_worker Woodhouse::Layout::Worker.new(:FooWorker, :foo, :only => { :size => "huge" }) 50 | default.add_worker Woodhouse::Layout::Worker.new(:FooWorker, :bar, :threads => 3) 51 | end 52 | layout.add_node Woodhouse::Layout::Node.new(:other) 53 | layout.node(:other).tap do |default| 54 | default.add_worker Woodhouse::Layout::Worker.new(:OtherWorker, :bat) 55 | end 56 | end 57 | } 58 | 59 | let(:overlapping_layout) { 60 | Woodhouse::Layout.new.tap do |layout| 61 | layout.add_node Woodhouse::Layout::Node.new(:default) 62 | layout.node(:default).tap do |default| 63 | default.add_worker Woodhouse::Layout::Worker.new(:FooWorker, :foo) 64 | default.add_worker Woodhouse::Layout::Worker.new(:FooWorker, :bar, :threads => 3) 65 | default.add_worker Woodhouse::Layout::Worker.new(:BarWorker, :baz) 66 | end 67 | end 68 | } 69 | 70 | let!(:common_config) { 71 | Woodhouse.global_configuration 72 | } 73 | 74 | end 75 | -------------------------------------------------------------------------------- /lib/woodhouse/runners/file_runner.rb: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | 3 | class Woodhouse::Runners::FileRunner < Woodhouse::Runner 4 | attr_accessor :jobs_dir, :queue_dir 5 | 6 | DEFAULT_QUEUE_DIR = '/tmp/woodhouse/queue' 7 | 8 | def initialize(worker, config) 9 | super 10 | 11 | server_info = config.server_info || {} 12 | self.queue_dir = server_info[:path] || DEFAULT_QUEUE_DIR 13 | self.jobs_dir = "#{queue_dir}/jobs" 14 | 15 | unless File.directory?(jobs_dir) # subdirectory of queue_dir 16 | config.logger.debug "[Woodhouse initialize] Creating queue directory '#{queue_dir}'" 17 | FileUtils.mkdir_p jobs_dir 18 | end 19 | end 20 | 21 | def subscribe 22 | until @shutdown do 23 | service_jobs 24 | sleep 5 25 | end 26 | end 27 | 28 | def spin_down 29 | @shutdown = true 30 | signal :spin_down 31 | end 32 | 33 | def service_jobs 34 | each_job do |job,queue_id| 35 | if can_service_job?(job) 36 | reserve_job(queue_id) { service_job(job) } 37 | end 38 | end 39 | end 40 | 41 | def each_job(&block) 42 | queue = Dir["#{queue_dir}/j-*"].sort 43 | 44 | queue.each do |job_path| 45 | job = YAML.load(File.read(job_path)) 46 | queue_id = File.basename(job_path)[2..-1] 47 | 48 | yield(job, queue_id) 49 | end 50 | end 51 | 52 | def reserve_job(queue_id, &block) 53 | enqueued = "#{queue_dir}/j-#{queue_id}" 54 | processing = "#{queue_dir}/p-#{queue_id}" 55 | failed = "#{queue_dir}/f-#{queue_id}" 56 | 57 | begin 58 | FileUtils.mv(enqueued, processing) 59 | 60 | if yield 61 | # Success, clean up 62 | File.unlink(processing) 63 | end 64 | 65 | rescue Errno::ENOENT 66 | # Another worker beat us to the job 67 | false 68 | 69 | rescue => err 70 | # Woodhouse internal error occurred during processing 71 | File.open(processing, 'a') {|f| f.write YAML.dump(err) } 72 | raise 73 | 74 | ensure 75 | # If file still hanging around then it failed 76 | FileUtils.mv(processing, failed) if File.exists?(processing) 77 | end 78 | end 79 | 80 | end 81 | -------------------------------------------------------------------------------- /spec/server_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::Server do 5 | it_should_behave_like "common" 6 | 7 | subject { Woodhouse::Server.new } 8 | 9 | it "should default to the :default node" do 10 | subject.node.should == :default 11 | end 12 | 13 | it "should expect the value to #layout= to be nil or a Layout" do 14 | subject.layout = Woodhouse::Layout.new 15 | subject.layout.should be_kind_of Woodhouse::Layout 16 | subject.layout = nil 17 | subject.layout.should be_nil 18 | if false # this craps out on JRuby 19 | begin 20 | oldlogger = Celluloid.logger 21 | Celluloid.logger = nil # It's going to crash 22 | expect do 23 | subject.layout = "foo" 24 | end.to raise_error 25 | ensure 26 | Celluloid.logger = oldlogger 27 | end 28 | end 29 | end 30 | 31 | it "should take a frozen clone of the layout" do 32 | layout = Woodhouse::Layout.new 33 | subject.layout = layout 34 | subject.layout.should_not be layout 35 | subject.layout.should be_frozen 36 | end 37 | 38 | context "#start" do 39 | 40 | it "should return false if a layout is not configured" do 41 | subject.start.should be_false 42 | end 43 | 44 | it "should return false if the set node doesn't exist in the layout" do 45 | subject.layout = populated_layout 46 | subject.node = :foo_bar_baz 47 | subject.start.should be_false 48 | end 49 | 50 | it "should return true and spin up workers if the node is valid" do 51 | subject.layout = populated_layout 52 | subject.start.should be_true 53 | # TODO: test for workers starting up 54 | end 55 | 56 | end 57 | 58 | context "#reload" do 59 | 60 | it "should shut down the server if a layout is not configured" 61 | 62 | it "should shut down the server if the set node doesn't exist in the layout" 63 | 64 | it "should shut down the server if the set node has no workers" 65 | 66 | it "should spin up new workers if they have been added to the node" 67 | 68 | it "should spin down workers if they have been removed from the node" 69 | 70 | end 71 | 72 | end 73 | -------------------------------------------------------------------------------- /lib/woodhouse/runners/hot_bunnies_runner.rb: -------------------------------------------------------------------------------- 1 | # 2 | # A Runner implementation that uses hot_bunnies, a JRuby AMQP client using the 3 | # Java client for RabbitMQ. This class can be loaded if hot_bunnies is not 4 | # available, but it will fail upon initialization. If you want to use this 5 | # runner (it's currently the only one that works very well), make sure to 6 | # add 7 | # 8 | # gem 'hot_bunnies' 9 | # 10 | # to your Gemfile. This runner will automatically be used in JRuby. 11 | # 12 | class Woodhouse::Runners::HotBunniesRunner < Woodhouse::Runner 13 | begin 14 | require 'hot_bunnies' 15 | rescue LoadError => err 16 | define_method(:initialize) {|*args| 17 | raise err 18 | } 19 | end 20 | 21 | def subscribe 22 | status :spinning_up 23 | client = HotBunnies.connect(@config.server_info) 24 | channel = client.create_channel 25 | channel.prefetch = 1 26 | queue = channel.queue(@worker.queue_name) 27 | exchange = channel.exchange(@worker.exchange_name, :type => :headers) 28 | queue.bind(exchange, :arguments => @worker.criteria.amqp_headers) 29 | worker = Celluloid.current_actor 30 | status :subscribed 31 | queue.subscribe(:ack => true).each(:blocking => false) do |headers, payload| 32 | status :receiving 33 | begin 34 | job = make_job(headers, payload) 35 | if can_service_job?(job) 36 | if service_job(job) 37 | headers.ack 38 | else 39 | headers.reject 40 | end 41 | else 42 | status :rejected 43 | headers.reject 44 | end 45 | status :subscribed 46 | rescue => err 47 | status :error 48 | begin 49 | headers.reject 50 | ensure 51 | worker.bail_out(err) 52 | end 53 | end 54 | end 55 | wait :spin_down 56 | status :closing 57 | ensure 58 | client.close 59 | end 60 | 61 | def spin_down 62 | signal :spin_down 63 | end 64 | 65 | def bail_out(err) 66 | status :bailing_out, "#{err.class}: #{err.message}" 67 | raise Woodhouse::BailOut, "#{err.class}: #{err.message}" 68 | end 69 | 70 | private 71 | 72 | def make_job(message, payload) 73 | Woodhouse::Job.new(@worker.worker_class_name, @worker.job_method) do |job| 74 | job.arguments = message.properties.headers.inject({}) {|h,(k,v)| 75 | h[k.to_s] = v.to_s 76 | h 77 | } 78 | job.payload = payload 79 | end 80 | end 81 | 82 | end 83 | -------------------------------------------------------------------------------- /THOUGHTS: -------------------------------------------------------------------------------- 1 | How I want Ganymede to work: 2 | 3 | # app/workers/foo_bar_worker.rb 4 | class FooBarWorker < Ganymede::Worker 5 | 6 | def foo(options) 7 | 8 | end 9 | 10 | def bar(options) 11 | 12 | end 13 | 14 | end 15 | 16 | # somewhere that jobs are dispatched 17 | 18 | def do_a_thing(because) 19 | @because = because 20 | FooBarWorker.foo(self) 21 | end 22 | 23 | def to_ganymede 24 | { 25 | :id => id, 26 | :class => self.class.name, 27 | :event_name => self.event.name, 28 | :size => estimate_job_size, 29 | :trigger => @because 30 | } 31 | end 32 | 33 | # config/initializers/ganymede.rb 34 | 35 | # Default configuration: all workers listen to all jobs, one thread per job, 36 | # no filtering. 37 | Ganymede.configure do |config| 38 | config.layout do |layout| 39 | # layout.no_default_node! if you do not want a default node type 40 | layout.node :bigjobs do |bigjobs| 41 | bigjobs.worker :FooBarWorker, :only => { :size => "big" }, :threads => 2 42 | bigjobs.worker :FooBarWorker, :only => { :size => "big", :trigger => "scheduled" }, :threads => 1 43 | end 44 | layout.default.worker :FooBarWorker, :only => { :size => "small", :trigger => "scheduled" }, :threads => 2 45 | layout.default.worker :FooBarWorker, :only => { :size => "small", :trigger => "on-demand" }, :threads => 4 46 | layout.default.worker :FooBarWorker, :only => { :size => "small", :trigger => "requested" }, :threads => 1 47 | end 48 | # config.middleware_out << Ganymede::Middleware::AutoSplatObjects 49 | end 50 | 51 | class Ganymede::Middleware::AutoSplatObjects 52 | 53 | def call(*opts) 54 | if opts.length == 1 and !opts.first.kind_of?(Hash) 55 | if opts.respond_to?(:to_ganymede) 56 | opts = opts.to_ganymede 57 | end 58 | end 59 | yield opts 60 | end 61 | 62 | end 63 | 64 | # equivalent to: 65 | layout = Ganymede::Layout.new 66 | default = Ganymede::Layout::Node.new(:default) 67 | default.default_configuration! 68 | default.add_worker Ganymede::Layout::Worker.new(:FooBarWorker, :foo, :only => { :size => "small", :trigger => "scheduled" }, :threads => 2) 69 | # ... 70 | layout.add_node default 71 | bigjobs = Ganymede::Layout::Node.new(:bigjobs) 72 | bigjobs.add_worker Ganymede::Layout::Worker.new(:FooBarWorker, :foo, :only => { :size => "big", :trigger => "scheduled" }, :threads => 1) 73 | 74 | server = Ganymede::Server.new(layout, :default) 75 | server.start # FIAT LUX 76 | 77 | # ... later ... 78 | # ... We really need a special worker just allocated for HugeEvent 79 | 80 | default.add_worker Ganymede::Worker.new(:FooBarWorker, :foo, :only => { :event_name => "HugeEvent" }, :threads => 1 81 | 82 | # Loads the new layout and starts up new workers as required 83 | server.layout = layout 84 | server.reload 85 | -------------------------------------------------------------------------------- /lib/woodhouse/runner.rb: -------------------------------------------------------------------------------- 1 | # 2 | # The abstract base class for actors in charge of finding and running jobs 3 | # of a given type. Runners will be allocated for each Woodhouse::Layout::Worker 4 | # in a layout. Woodhouse::Layout::Worker#threads indicates how many Runners should 5 | # be spawned for each job type. 6 | # 7 | # The lifecycle of a Runner is to be created by Woodhouse::Scheduler::WorkerSet, 8 | # and to automatically begin subscribing as soon as it is initialized. At some 9 | # point, the actor will receive the +spin_down+ message, at which case it must 10 | # cease all work and return from +subscribe+. 11 | # 12 | # Whenever a Runner receives a job on its queue, it should convert it into a 13 | # Workling::Job and pass it to +service_job+ after confirming with 14 | # +can_service_job?+ that this is an appropriate job for this queue. 15 | # 16 | # Runners should always subscribe to queues in ack mode. Messages should be 17 | # acked after they finish, and rejected if the job is inappropriate for this 18 | # worker or if it raises an exception. 19 | # 20 | # TODO: document in more detail the contract between Runner and Dispatcher over 21 | # AMQP exchanges, and how Woodhouse uses AMQP to distribute jobs. 22 | # 23 | class Woodhouse::Runner 24 | include Woodhouse::Util 25 | include Celluloid 26 | 27 | def initialize(worker, config) 28 | @worker = worker 29 | @config = config 30 | @status_client = Woodhouse::Watchdog.client 31 | @config.logger.debug "Thread for #{@worker.describe} ready and waiting for jobs" 32 | end 33 | 34 | # Implement this in a subclass. When this message is received by an actor, it should 35 | # finish whatever job it is currently doing, gracefully disconnect from AMQP, and 36 | # stop the subscribe loop. 37 | def spin_down 38 | raise NotImplementedError, "implement #spin_down in a subclass of Woodhouse::Runner" 39 | end 40 | 41 | def current_status 42 | @status 43 | end 44 | 45 | private 46 | 47 | # Implement this in a subclass. When this message is received by an actor, it should 48 | # connect to AMQP and start pulling jobs off the queue. This method should not finish 49 | # until spin_down is called. 50 | def subscribe # :doc: 51 | raise NotImplementedError, "implement #subscribe in a subclass of Woodhouse::Runner" 52 | end 53 | 54 | # Returns +true+ if the Job's arguments match this worker's QueueCriteria, else +false+. 55 | def can_service_job?(job) # :doc: 56 | @worker.accepts_job?(job) 57 | end 58 | 59 | # Executes a Job. See Woodhouse::JobExecution. 60 | def service_job(job) # :doc: 61 | status :servicing 62 | Woodhouse::JobExecution.new(@config, job).execute 63 | end 64 | 65 | def status(stat, message = nil) 66 | message ||= @worker.describe 67 | @status_client.report stat, message 68 | end 69 | 70 | end 71 | -------------------------------------------------------------------------------- /lib/woodhouse/watchdog.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Watchdog 2 | include Celluloid 3 | 4 | def initialize 5 | @actors = {} 6 | @listeners = [] 7 | end 8 | 9 | def report(id, status) 10 | last_status = @actors[id] 11 | @actors[id] = status 12 | notify id, Transition.new(last_status, status) 13 | end 14 | 15 | def status_report 16 | {}.tap do |hash| 17 | @actors.each do |id, status| 18 | hash[id.to_s] = status.to_h 19 | end 20 | end 21 | end 22 | 23 | def listen(listener) 24 | @listeners << listener 25 | end 26 | 27 | private 28 | 29 | def notify(id, keyw = {}) 30 | @listeners.each do |listen| 31 | listen.call id, keyw 32 | end 33 | end 34 | 35 | class << self 36 | 37 | def instance 38 | Celluloid::Actor[:woodhouse_watchdog] 39 | end 40 | 41 | def start 42 | @supervisor ||= supervise_as :woodhouse_watchdog 43 | end 44 | 45 | def stop 46 | if @supervisor 47 | supervisor, @supervisor = @supervisor, nil 48 | supervisor.terminate 49 | end 50 | end 51 | 52 | def client(id = nil) 53 | Client.new(instance, id) 54 | end 55 | 56 | def listen(listener = nil, &blk) 57 | if instance 58 | instance.listen listener || blk 59 | end 60 | end 61 | 62 | end 63 | 64 | class Transition 65 | attr_reader :old, :new 66 | 67 | def initialize(old, new) 68 | @old = old 69 | @new = new 70 | end 71 | 72 | def name 73 | "#{old_name} -> #{new_name}" 74 | end 75 | 76 | def old_name 77 | old && old.name 78 | end 79 | 80 | def new_name 81 | new && new.name 82 | end 83 | 84 | def message 85 | new.message 86 | end 87 | 88 | def duration 89 | old && new.time - old.time 90 | end 91 | 92 | def duration_s 93 | duration && " (#{duration}s)" 94 | end 95 | 96 | def to_s 97 | "{ #{name} } #{message}#{duration_s}" 98 | end 99 | 100 | end 101 | 102 | class Status 103 | attr_reader :name, :message, :time 104 | 105 | def initialize(name, message, time = Time.now) 106 | @name = name.to_sym 107 | @message = message.dup.freeze 108 | @time = time.dup.freeze 109 | 110 | freeze 111 | end 112 | 113 | def to_h 114 | { name: @name, message: @message, time: @time } 115 | end 116 | 117 | end 118 | 119 | class Client 120 | 121 | def initialize(watchdog, id = nil) 122 | @watchdog = watchdog 123 | @id = id || detect_id || Celluloid.uuid 124 | end 125 | 126 | def detect_id 127 | Celluloid.current_actor.object_id 128 | end 129 | 130 | def report(name, message) 131 | if @watchdog 132 | @watchdog.report @id, Status.new(name, message) 133 | end 134 | end 135 | 136 | end 137 | 138 | end 139 | -------------------------------------------------------------------------------- /lib/woodhouse/worker.rb: -------------------------------------------------------------------------------- 1 | # 2 | # Classes which include this module become automatically visible to 3 | # MixinRegistry (the default way of finding jobs in Woodhouse). 4 | # All public methods of the class are automatically made available as 5 | # jobs. 6 | # 7 | # Classes including Woodhouse::Worker also get access to the +logger+ 8 | # method, which will be the same logger globally configured for the current 9 | # Layout. 10 | # 11 | # Classes including Woodhouse::Worker also have convenience shortcuts 12 | # for dispatching jobs. Any job defined on the class can be dispatched 13 | # asynchronously by calling ClassName.async_job_name(options). 14 | # 15 | # == Example 16 | # 17 | # class PamPoovey 18 | # include Woodhouse::Worker 19 | # 20 | # # This is available as the job PamPoovey#do_hr 21 | # def do_hr(options) 22 | # logger.info "Out comes the dolphin puppet" 23 | # end 24 | # 25 | # private 26 | # 27 | # # This is not picked up as a job 28 | # def fight_club 29 | # # ... 30 | # end 31 | # end 32 | # 33 | # # later ... 34 | # 35 | # Woodhouse::MixinRegistry.new[:PamPoovey] # => PamPoovey 36 | # PamPoovey.async_do_hr(:employee => "Lana") 37 | # 38 | module Woodhouse::Worker 39 | 40 | def self.included(into) 41 | into.extend ClassMethods 42 | into.set_worker_name into.name unless into.name.nil? 43 | end 44 | 45 | # The current Woodhouse logger. Set by the runner. Don't expect it to be set 46 | # if you create the object yourself. If you want to be able to run job methods 47 | # directly, you should account for setting +logger+. 48 | attr_accessor :logger 49 | 50 | module ClassMethods 51 | 52 | def worker_name 53 | @worker_name 54 | end 55 | 56 | # Sets the name for this worker class if not already set (i.e., if it's 57 | # an anonymous class). The first time the name for the worker is set, 58 | # it becomes registered with MixinRegistry. After that, attempting to 59 | # change the worker class will raise ArgumentError. 60 | def set_worker_name(name) 61 | if @worker_name 62 | raise ArgumentError, "cannot change worker name" 63 | else 64 | if name and !name.empty? 65 | @worker_name = name.to_sym 66 | Woodhouse::MixinRegistry.register self 67 | end 68 | end 69 | end 70 | 71 | def available_jobs 72 | @available_jobs ||= public_instance_methods(false) 73 | end 74 | 75 | def only_jobs(*jobs) 76 | @available_jobs = jobs 77 | end 78 | 79 | def exclude_jobs(*jobs) 80 | @available_jobs -= jobs 81 | end 82 | 83 | # You can dispatch a job +baz+ on class +FooBar+ by calling FooBar.async_baz. 84 | def method_missing(method, *args, &block) 85 | if method.to_s =~ /^asynch?_(.*)/ 86 | if instance_methods(false).detect{|meth| meth.to_s == $1 } 87 | Woodhouse.dispatch(@worker_name, $1, args.first) 88 | else 89 | super 90 | end 91 | else 92 | super 93 | end 94 | end 95 | 96 | end 97 | 98 | end 99 | -------------------------------------------------------------------------------- /lib/woodhouse/scheduler.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::Scheduler 2 | include Woodhouse::Util 3 | include Celluloid 4 | 5 | class SpunDown < StandardError 6 | 7 | end 8 | 9 | class WorkerSet 10 | include Woodhouse::Util 11 | include Celluloid 12 | 13 | attr_reader :worker 14 | trap_exit :worker_died 15 | 16 | def initialize(scheduler, worker, config) 17 | expect_arg_or_nil :worker, Woodhouse::Layout::Worker, worker 18 | @scheduler = scheduler 19 | @worker_def = worker 20 | @config = config 21 | @threads = [] 22 | spin_up 23 | end 24 | 25 | def spin_down 26 | @spinning_down = true 27 | @threads.each_with_index do |thread, idx| 28 | @config.logger.debug "Spinning down thread #{idx} for worker #{@worker_def.describe}" 29 | thread.spin_down 30 | thread.terminate 31 | end 32 | @scheduler.remove_worker(@worker_def) 33 | signal :spun_down 34 | end 35 | 36 | def wait_until_done 37 | wait :spun_down 38 | end 39 | 40 | private 41 | 42 | def worker_died(actor, reason) 43 | if reason 44 | @config.logger.info "Worker died (#{reason.class}: #{reason.message}). Spinning down." 45 | @threads.delete actor 46 | raise Woodhouse::BailOut 47 | end 48 | end 49 | 50 | def spin_up 51 | @worker_def.threads.times do |idx| 52 | @config.logger.debug "Spinning up thread #{idx} for worker #{@worker_def.describe}" 53 | worker = @config.runner_type.new_link(@worker_def, @config) 54 | @threads << worker 55 | worker.async.subscribe 56 | end 57 | end 58 | 59 | end 60 | 61 | trap_exit :worker_set_died 62 | 63 | def initialize(config) 64 | @config = config 65 | @worker_sets = {} 66 | end 67 | 68 | def start_worker(worker) 69 | @config.logger.debug "Starting worker #{worker.describe}" 70 | unless @worker_sets.has_key?(worker) 71 | @worker_sets[worker] = WorkerSet.new_link(Celluloid.current_actor, worker, @config) 72 | true 73 | else 74 | false 75 | end 76 | end 77 | 78 | def stop_worker(worker, wait = false) 79 | if set = @worker_sets[worker] 80 | @config.logger.debug "Spinning down worker #{worker.describe}" 81 | set.spin_down 82 | end 83 | end 84 | 85 | def running_worker?(worker) 86 | @worker_sets.has_key?(worker) 87 | end 88 | 89 | def spin_down 90 | @spinning_down = true 91 | @config.logger.debug "Spinning down all workers" 92 | @worker_sets.each do |worker, set| 93 | set.spin_down 94 | set.terminate 95 | end 96 | end 97 | 98 | def remove_worker(worker) 99 | @worker_sets.delete(worker) 100 | end 101 | 102 | def worker_set_died(actor, reason) 103 | if reason 104 | @config.logger.info "Worker set died (#{reason.class}: #{reason.message}). Spinning down." 105 | begin 106 | spin_down 107 | ensure 108 | raise reason 109 | end 110 | end 111 | end 112 | 113 | end 114 | -------------------------------------------------------------------------------- /lib/woodhouse.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse 2 | WoodhouseError = Class.new(StandardError) 3 | WorkerNotFoundError = Class.new(WoodhouseError) 4 | ConnectionError = Class.new(WoodhouseError) 5 | ConfigurationError = Class.new(WoodhouseError) 6 | FatalError = Class.new(WoodhouseError) 7 | BailOut = Class.new(FatalError) 8 | 9 | module Util 10 | 11 | private 12 | 13 | def expect_arg(name, klass, value) 14 | unless value.kind_of?(klass) 15 | raise ArgumentError, "expected #{name} to be a #{klass.name}, got #{value.class}" 16 | end 17 | end 18 | 19 | def expect_arg_or_nil(name, klass, value) 20 | expect_arg(name, klass, value) unless value.nil? 21 | end 22 | 23 | # Cheap knockoff, suffices for my simple purposes 24 | def camelize(string) 25 | string.split(/_/).map{ |word| word.capitalize }.join('') 26 | end 27 | 28 | end 29 | 30 | # TODO: hate keeping global state in this class. I need to push 31 | # some of this down into NodeConfiguration or something like it. 32 | module GlobalMethods 33 | 34 | def logger 35 | global_configuration.logger 36 | end 37 | 38 | def global_configuration 39 | @global_configuration ||= Woodhouse::NodeConfiguration.default 40 | end 41 | 42 | def configure 43 | @global_configuration ||= Woodhouse::NodeConfiguration.default 44 | yield @global_configuration 45 | end 46 | 47 | def global_layout 48 | @global_layout ||= Woodhouse::Layout.default 49 | end 50 | 51 | def layout 52 | @global_layout ||= Woodhouse::Layout.new 53 | yield Woodhouse::LayoutBuilder.new(Woodhouse.global_configuration, @global_layout) 54 | end 55 | 56 | # Returns +true+ on JRuby, Rubinius, or MRI 1.9. +false+ otherwise. 57 | def threading_safe? 58 | RUBY_VERSION.to_f >= 1.9 or %w[jruby rbx].include?(RUBY_ENGINE) 59 | end 60 | 61 | def dispatcher 62 | global_configuration.dispatcher 63 | end 64 | 65 | def dispatch(*a) 66 | dispatcher.dispatch(*a) 67 | end 68 | 69 | def update_job(*a) 70 | dispatcher.update_job(*a) 71 | end 72 | 73 | def watchdog 74 | Woodhouse::Watchdog.instance 75 | end 76 | 77 | end 78 | 79 | extend GlobalMethods 80 | 81 | end 82 | 83 | require 'celluloid' 84 | require 'woodhouse/job' 85 | require 'woodhouse/layout' 86 | require 'woodhouse/layout_builder' 87 | require 'woodhouse/scheduler' 88 | require 'woodhouse/server' 89 | require 'woodhouse/queue_criteria' 90 | require 'woodhouse/node_configuration' 91 | require 'woodhouse/registry' 92 | require 'woodhouse/mixin_registry' 93 | require 'woodhouse/worker' 94 | require 'woodhouse/job_execution' 95 | require 'woodhouse/runners' 96 | require 'woodhouse/dispatchers' 97 | require 'woodhouse/middleware_stack' 98 | require 'woodhouse/middleware' 99 | require 'woodhouse/rails' 100 | require 'woodhouse/process' 101 | require 'woodhouse/layout_serializer' 102 | require 'woodhouse/trigger_set' 103 | require 'woodhouse/watchdog' 104 | 105 | require 'woodhouse/extension' 106 | require 'woodhouse/extensions/progress' 107 | require 'woodhouse/extensions/new_relic' 108 | -------------------------------------------------------------------------------- /lib/woodhouse/node_configuration.rb: -------------------------------------------------------------------------------- 1 | class Woodhouse::NodeConfiguration 2 | include Woodhouse::Util 3 | 4 | attr_accessor :registry, :server_info, :runner_type, :dispatcher_type, :logger, :default_threads 5 | attr_accessor :dispatcher_middleware, :runner_middleware 6 | attr_accessor :triggers 7 | 8 | def initialize 9 | self.default_threads = 1 10 | self.dispatcher_middleware = Woodhouse::MiddlewareStack.new(self) 11 | self.runner_middleware = Woodhouse::MiddlewareStack.new(self) 12 | self.server_info = {} 13 | self.triggers = Woodhouse::TriggerSet.new 14 | yield self if block_given? 15 | end 16 | 17 | def at(event_name, &blk) 18 | triggers.add(event_name, &blk) 19 | end 20 | 21 | def dispatcher 22 | @dispatcher ||= dispatcher_type.new(self) 23 | end 24 | 25 | def dispatcher_type=(value) 26 | if value.respond_to?(:to_sym) 27 | value = lookup_key(value, :Dispatcher) 28 | end 29 | @dispatcher = nil 30 | @dispatcher_type = value 31 | end 32 | 33 | def runner_type=(value) 34 | if value.respond_to?(:to_sym) 35 | value = lookup_key(value, :Runner) 36 | end 37 | @dispatcher = nil 38 | @runner_type = value 39 | end 40 | 41 | def server_info=(hash) 42 | @server_info = hash ? symbolize_keys(hash) : {} 43 | end 44 | 45 | def extension(name, opts = {}, &blk) 46 | Woodhouse::Extension.install_extension(name, self, opts, &blk) 47 | end 48 | 49 | def load_yaml(path, keyw = {}) 50 | return unless File.exist?(path) 51 | 52 | section = keyw[:section] 53 | environment = keyw[:environment] 54 | 55 | config_info = YAML.load(File.read(path)) 56 | 57 | if environment 58 | config_info = config_info[environment] 59 | end 60 | if section 61 | config_info = { section => config_info } 62 | end 63 | 64 | set config_info 65 | end 66 | 67 | def set(hash) 68 | return unless hash 69 | 70 | hash.each do |key, val| 71 | if respond_to?("#{key}=") 72 | send("#{key}=", val) 73 | end 74 | end 75 | end 76 | 77 | private 78 | 79 | def lookup_key(key, namespace) 80 | const = Woodhouse.const_get("#{namespace}s").const_get("#{camelize(key.to_s)}#{namespace}") 81 | unless const 82 | raise NameError, "couldn't find Woodhouse::#{namespace}s::#{camelize(key.to_s)}#{namespace} (from #{key})" 83 | end 84 | const 85 | end 86 | 87 | def symbolize_keys(hash) 88 | hash.inject({}){|h,(k,v)| 89 | h[k.to_sym] = v 90 | h 91 | } 92 | end 93 | 94 | # TODO: detect defaults based on platform 95 | def self.default 96 | new do |config| 97 | config.registry = Woodhouse::MixinRegistry.new 98 | config.server_info = nil 99 | config.runner_type = Woodhouse::Runners.guess 100 | config.dispatcher_type = :local 101 | config.logger = Logger.new("/dev/null") 102 | config.dispatcher_middleware << Woodhouse::Middleware::LogDispatch 103 | config.runner_middleware << Woodhouse::Middleware::LogJobs 104 | config.runner_middleware << Woodhouse::Middleware::AssignLogger 105 | end 106 | end 107 | 108 | end 109 | -------------------------------------------------------------------------------- /lib/woodhouse/extensions/progress.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require 'json' 3 | require 'digest/sha1' 4 | 5 | module Woodhouse::Progress 6 | 7 | class << self 8 | 9 | attr_accessor :client 10 | 11 | def install_extension(configuration, opts = {}, &blk) 12 | install!(configuration) 13 | end 14 | 15 | def install!(configuration = Woodhouse.global_configuration) 16 | self.client = Woodhouse::Progress::BunnyProgressClient 17 | configuration.runner_middleware << Woodhouse::Progress::InjectProgress 18 | end 19 | 20 | def pull(job_id) 21 | client.new(Woodhouse.global_configuration).pull(job_id) 22 | end 23 | 24 | def pull_raw(job_id) 25 | client.new(Woodhouse.global_configuration).pull_raw(job_id) 26 | end 27 | 28 | end 29 | 30 | class ProgressClient 31 | attr_accessor :config 32 | 33 | def initialize(config) 34 | self.config = config 35 | end 36 | 37 | def pull(job_id) 38 | progress = pull_raw(job_id) 39 | if progress 40 | JSON.parse(progress) 41 | end 42 | end 43 | 44 | def pull_raw(job_id) 45 | pull_progress(job_id) 46 | end 47 | 48 | protected 49 | 50 | def pull_progress(job_id) 51 | raise NotImplementedError 52 | end 53 | 54 | end 55 | 56 | class BunnyProgressClient < ProgressClient 57 | 58 | protected 59 | 60 | def pull_progress(job_id) 61 | bunny = Bunny.new(config.server_info) 62 | 63 | bunny.start 64 | begin 65 | channel = bunny.create_channel 66 | exchange = channel.direct("woodhouse.progress") 67 | queue = channel.queue(job_id, :arguments => {"x-expires" => 5*60*1000}) 68 | queue.bind(exchange, :routing_key => job_id) 69 | payload = nil 70 | queue.message_count.times do 71 | _, _, next_payload = queue.pop 72 | payload = next_payload if next_payload 73 | end 74 | payload 75 | ensure 76 | bunny.stop 77 | end 78 | end 79 | 80 | end 81 | 82 | 83 | class StatusTicker 84 | attr_accessor :top 85 | attr_accessor :current 86 | attr_accessor :status 87 | 88 | def initialize(job, name, keyw = {}) 89 | self.job = job 90 | self.name = name 91 | self.top = keyw[:top] 92 | self.current = keyw.fetch(:start, 0) 93 | self.status = keyw[:status] 94 | end 95 | 96 | def to_hash 97 | { name => count_attributes.merge( "status" => status ) } 98 | end 99 | 100 | def count_attributes 101 | { "current" => current }.tap do |h| 102 | h["top"] = top if top 103 | end 104 | end 105 | 106 | def tick(keyw = {}) 107 | status = keyw[:status] 108 | count = keyw[:count] 109 | by = keyw[:by] || 1 110 | new_top = keyw[:top] 111 | 112 | if status 113 | self.status = status 114 | end 115 | 116 | if current 117 | next_tick = count || current + by 118 | 119 | self.current = next_tick 120 | end 121 | 122 | self.top = new_top if new_top 123 | 124 | job.update_progress(to_hash) 125 | end 126 | 127 | alias call tick 128 | 129 | protected 130 | 131 | attr_accessor :job, :name 132 | 133 | end 134 | 135 | module JobWithProgress 136 | 137 | attr_accessor :progress_sink 138 | 139 | def status_ticker(name, keyw = {}) 140 | StatusTicker.new(self, name, keyw) 141 | end 142 | 143 | def update_progress(data) 144 | job = self 145 | sink = progress_sink 146 | Celluloid.internal_pool.get { sink.update_job(job, data) } 147 | nil 148 | end 149 | 150 | def progress_sink 151 | @progress_sink ||= Woodhouse 152 | end 153 | 154 | end 155 | 156 | class InjectProgress < Woodhouse::Middleware 157 | 158 | def call(job, worker) 159 | job.extend JobWithProgress 160 | yield job, worker 161 | end 162 | 163 | end 164 | 165 | end 166 | 167 | Woodhouse::Extension.register :progress, Woodhouse::Progress 168 | -------------------------------------------------------------------------------- /spec/layout_spec.rb: -------------------------------------------------------------------------------- 1 | require 'woodhouse' 2 | require File.dirname(File.expand_path(__FILE__)) + '/shared_contexts' 3 | 4 | describe Woodhouse::Layout do 5 | 6 | context "#add_node" do 7 | 8 | it "should only accept Woodhouse::Node objects" 9 | 10 | end 11 | 12 | context "#frozen_clone" do 13 | 14 | it "should return a frozen copy where all sub-objects are also frozen copies" 15 | 16 | end 17 | 18 | context "#changes_from" do 19 | 20 | it "should return a Woodhouse::Layout::Changes object where this layout is the new one" 21 | 22 | end 23 | 24 | end 25 | 26 | describe Woodhouse::Layout::Node do 27 | it_should_behave_like "common" 28 | 29 | context "#add_node" do 30 | 31 | it "should take a string or a symbol and create a node with that name" do 32 | empty_layout.add_node :orz 33 | empty_layout.add_node 'vux' 34 | empty_layout.add_node Woodhouse::Layout::Node.new(:androsynth) 35 | empty_layout.node(:orz).should be_kind_of(Woodhouse::Layout::Node) 36 | empty_layout.node(:vux).should be_kind_of(Woodhouse::Layout::Node) 37 | empty_layout.node(:androsynth).should be_kind_of(Woodhouse::Layout::Node) 38 | end 39 | 40 | end 41 | 42 | context "#default_configuration!" do 43 | 44 | it "should configure one worker thread for every job available" do 45 | layout = empty_layout 46 | config = common_config 47 | config.registry = { 48 | :FooBarWorker => FakeWorker, 49 | :BarBazWorker => FakeWorker, 50 | :BazBatWorker => FakeWorker 51 | } 52 | layout.add_node Woodhouse::Layout::Node.new(:default) 53 | layout.node(:default).default_configuration! config 54 | layout.node(:default).workers.should have(6).workers 55 | # FooBar#foo, FooBar#bar, BarBaz#foo... 56 | end 57 | 58 | it "should pay attention to the config's default_threads" do 59 | layout = empty_layout 60 | config = common_config 61 | config.default_threads = 10 62 | config.registry = { 63 | :OrzWorker => FakeWorker 64 | } 65 | layout.add_node :default 66 | layout.node(:default).default_configuration! config 67 | layout.node(:default).workers.should have(2).workers 68 | layout.node(:default).workers.first.threads.should == 10 69 | end 70 | 71 | end 72 | 73 | end 74 | 75 | describe Woodhouse::Layout::Worker do 76 | 77 | it "should default to 1 thread" 78 | 79 | it "should default to a wide-open criteria" 80 | 81 | it "should automatically convert the :only key to a Woodhouse::QueueCriteria" 82 | 83 | end 84 | 85 | describe Woodhouse::Layout::Changes do 86 | it_should_behave_like "common" 87 | 88 | context "when the new layout is empty" do 89 | 90 | subject { Woodhouse::Layout::Changes.new(empty_layout, populated_layout, :default) } 91 | 92 | it "should drop all workers and add none" do 93 | subject.adds.should be_empty 94 | subject.drops.should have(3).dropped_workers 95 | end 96 | 97 | end 98 | 99 | context "when the old layout is empty" do 100 | 101 | subject { Woodhouse::Layout::Changes.new(populated_layout, empty_layout, :default) } 102 | 103 | it "should add all workers and drop none" do 104 | subject.drops.should be_empty 105 | subject.adds.should have(3).added_workers 106 | end 107 | 108 | end 109 | 110 | context "when the new layout is nil" do 111 | 112 | subject { Woodhouse::Layout::Changes.new(nil, populated_layout, :default) } 113 | 114 | it "should drop all workers and add none" do 115 | subject.adds.should be_empty 116 | subject.drops.should have(3).dropped_workers 117 | end 118 | 119 | end 120 | 121 | context "when the old layout is nil" do 122 | 123 | subject { Woodhouse::Layout::Changes.new(populated_layout, nil, :default) } 124 | 125 | it "should add all workers and drop none" do 126 | subject.drops.should be_empty 127 | subject.adds.should have(3).added_workers 128 | end 129 | 130 | end 131 | 132 | context "when both layouts are specified and they overlap" do 133 | 134 | subject { Woodhouse::Layout::Changes.new(overlapping_layout, populated_layout, :default) } 135 | 136 | it "should add some workers, drop some, and leave some alone" do 137 | subject.drops.should have(1).dropped_worker 138 | subject.adds.should have(1).added_worker 139 | end 140 | 141 | end 142 | 143 | end 144 | -------------------------------------------------------------------------------- /lib/woodhouse/layout.rb: -------------------------------------------------------------------------------- 1 | module Woodhouse 2 | 3 | # 4 | # A Layout describes the configuration of a set of Woodhouse Server instances. 5 | # Each Server runs all of the workers assigned to a single Node. 6 | # 7 | # Layouts and their contents (Node and Worker instances) are all plain data, 8 | # suitable to being serialized, saved out, passed around, etc. 9 | # 10 | # Woodhouse clients do not need to know anything about the Layout to dispatch 11 | # jobs, but servers rely on the Layout to know which jobs to serve. The basic 12 | # process of setting up a Woodhouse server is to create a layout with one or 13 | # more nodes and then pass it to Woodhouse::Server to serve. 14 | # 15 | # There is a default layout suitable for many applications, available as 16 | # Woodhouse::Layout.default. It has a single node named :default, which has 17 | # the default node configuration -- one worker for every job. If you do not 18 | # need to distribute different sets of jobs to different workers, the default 19 | # layout should serve you. 20 | # 21 | # TODO: A nicer DSL for creating and tweaking Layouts. 22 | # 23 | class Layout 24 | include Woodhouse::Util 25 | 26 | def initialize 27 | @nodes = [] 28 | end 29 | 30 | # Returns a frozen list of the nodes assigned to this layout. 31 | def nodes 32 | @nodes.frozen? ? @nodes : @nodes.dup.freeze 33 | end 34 | 35 | # Adds a Node to this layout. If +node+ is a Symbol, a Node will be 36 | # automatically created with that name. 37 | # 38 | # # Example: 39 | # 40 | # layout.add_node Woodhouse::Layout::Node.new(:isis) 41 | # 42 | # # Is equivalent to 43 | # 44 | # layout.add_node :isis 45 | # 46 | def add_node(node) 47 | if node.respond_to?(:to_sym) 48 | node = Woodhouse::Layout::Node.new(node.to_sym) 49 | end 50 | expect_arg :node, Woodhouse::Layout::Node, node 51 | @nodes << node 52 | node 53 | end 54 | 55 | # Looks up a Node by name and returns it. 56 | def node(name) 57 | name = name.to_sym 58 | @nodes.detect{|node| 59 | node.name == name 60 | } 61 | end 62 | 63 | # Returns a frozen copy of this Layout and all of its child Node and 64 | # Worker objects. Woodhouse::Server always takes a frozen copy of the 65 | # layout it is given. It is thus safe to modify the same layout 66 | # subsequently, and the changes only take effect when the layout is 67 | # passed to the server again and Woodhouse::Server#reload is called. 68 | def frozen_clone 69 | clone.tap do |cloned| 70 | cloned.nodes = @nodes.map{|node| node.frozen_clone }.freeze 71 | cloned.freeze 72 | end 73 | end 74 | 75 | # Returns a set of Changes necessary to move from +other_layout+ to this 76 | # layout. This is used to permit live reconfiguration of servers by only 77 | # spinning up and down nodes/workers which have changed. 78 | def changes_from(other_layout, node) 79 | Woodhouse::Layout::Changes.new(self, other_layout, node) 80 | end 81 | 82 | def dump(serializer = Woodhouse::LayoutSerializer) 83 | serializer.dump(self) 84 | end 85 | 86 | def self.load(dumped, serializer = Woodhouse::LayoutSerializer) 87 | serializer.load(dumped) 88 | end 89 | 90 | # The default layout, for convenience purposes. Has one node +:default+, 91 | # which has the default configuration (see Woodhouse::Layout::Node#default_configuration!) 92 | def self.default 93 | new.tap do |layout| 94 | layout.add_node :default 95 | layout.node(:default).default_configuration!(Woodhouse.global_configuration) 96 | end 97 | end 98 | 99 | protected 100 | 101 | attr_writer :nodes 102 | 103 | # 104 | # A Node describes the set of workers present on a single Server. 105 | # 106 | # More information about Woodhouse's layout system can be found in the 107 | # documentation for Woodhouse::Layout. 108 | # 109 | class Node 110 | include Woodhouse::Util 111 | 112 | attr_reader :name 113 | 114 | def initialize(name) 115 | @name = name.to_sym 116 | @workers = [] 117 | end 118 | 119 | # Returns a frozen list of workers assigned to this node. 120 | def workers 121 | @workers.frozen? ? @workers : @workers.dup.freeze 122 | end 123 | 124 | # Adds a Worker to this node. 125 | def add_worker(worker) 126 | expect_arg :worker, Woodhouse::Layout::Worker, worker 127 | @workers << worker 128 | end 129 | 130 | def remove_worker(worker) 131 | expect_arg :worker, Woodhouse::Layout::Worker, worker 132 | @workers.delete(worker) 133 | end 134 | 135 | def worker_for_job(job) 136 | @workers.detect {|worker| 137 | worker.accepts_job?(job) 138 | } 139 | end 140 | 141 | def clear 142 | @workers.clear 143 | end 144 | 145 | # Configures this node with one worker per job (jobs obtained 146 | # from Registry#each). The +default_threads+ value of the given 147 | # +config+ is used to determine how many threads should be 148 | # assigned to each worker. 149 | def default_configuration!(config, options = {}) 150 | options[:threads] ||= config.default_threads 151 | config.registry.each do |name, klass| 152 | klass.available_jobs.each do |method| 153 | add_worker Woodhouse::Layout::Worker.new(name, method, options) 154 | end 155 | end 156 | end 157 | 158 | # Used by Layout#frozen_clone 159 | def frozen_clone # :nodoc: 160 | clone.tap do |cloned| 161 | cloned.workers = @workers.map{|worker| worker.frozen_clone }.freeze 162 | cloned.freeze 163 | end 164 | end 165 | 166 | protected 167 | 168 | attr_writer :workers 169 | end 170 | 171 | # 172 | # A Worker describes a single job that is performed on a Server. 173 | # One or more Runner actors are created for every Worker in a Node. 174 | # 175 | # Any Worker has three parameters used to route jobs to it: 176 | # 177 | # +worker_class_name+:: 178 | # This is generally a class name. It's looked up 179 | # in a Registry and used to instantiate a job object. 180 | # +job_method+:: 181 | # This is a method on the object called up with +worker_class_name+. 182 | # +criteria+:: 183 | # A hash of values (actually, a QueueCriteria object) used 184 | # to filter only specific jobs to this worker. When a job is dispatched, 185 | # its +arguments+ are compared with a worker's +criteria+. This is 186 | # done via an AMQP headers exchange (TODO: need to have a central document 187 | # to reference on how Woodhouse uses AMQP and jobs are dispatched) 188 | # 189 | class Worker 190 | attr_reader :worker_class_name, :job_method, :threads, :criteria 191 | attr_accessor :flags 192 | 193 | def initialize(worker_class_name, job_method, opts = {}) 194 | opts = opts.clone 195 | self.worker_class_name = worker_class_name 196 | self.job_method = job_method 197 | self.threads = opts.delete(:threads) || 1 198 | criteria = opts.delete(:only) 199 | self.flags = opts 200 | self.criteria = criteria 201 | end 202 | 203 | def exchange_name 204 | "#{worker_class_name}_#{job_method}".downcase 205 | end 206 | 207 | def queue_name 208 | exchange_name + criteria.queue_key 209 | end 210 | 211 | def worker_class_name=(value) 212 | @worker_class_name = value.to_sym 213 | end 214 | 215 | def job_method=(value) 216 | @job_method = value.to_sym 217 | end 218 | 219 | def threads=(value) 220 | @threads = value.to_i 221 | end 222 | 223 | def criteria=(value) 224 | @criteria = Woodhouse::QueueCriteria.new(value, flags).freeze 225 | end 226 | 227 | def frozen_clone 228 | clone.freeze 229 | end 230 | 231 | def describe 232 | "#@worker_class_name##@job_method(#{@criteria.describe})" 233 | end 234 | 235 | def accepts_job?(job) 236 | criteria.matches?(job.arguments) 237 | end 238 | 239 | # TODO: want to recognize increases and decreases in numbers of 240 | # threads and make minimal changes 241 | def ==(other) 242 | [worker_class_name, job_method, 243 | threads, criteria] == 244 | [other.worker_class_name, other.job_method, 245 | other.threads, other.criteria] 246 | end 247 | 248 | end 249 | 250 | # 251 | # A diff between two Layouts, used to determine what workers need to be 252 | # spun up and down when a layout change is sent to a Server. 253 | # 254 | class Changes 255 | 256 | def initialize(new_layout, old_layout, node_name) 257 | @new_layout = new_layout 258 | @new_node = @new_layout && @new_layout.node(node_name) 259 | @old_layout = old_layout 260 | @old_node = @old_layout && @old_layout.node(node_name) 261 | @node_name = node_name 262 | end 263 | 264 | def adds 265 | new_workers.reject{|worker| 266 | old_workers.member? worker 267 | } 268 | end 269 | 270 | def drops 271 | old_workers.reject{|worker| 272 | new_workers.member? worker 273 | } 274 | end 275 | 276 | private 277 | 278 | def old_workers 279 | @old_workers ||= @old_node ? @old_node.workers : [] 280 | end 281 | 282 | def new_workers 283 | @new_workers ||= @new_node ? @new_node.workers : [] 284 | end 285 | 286 | end 287 | 288 | end 289 | 290 | end 291 | --------------------------------------------------------------------------------