woodhouse 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. data/.gitignore +5 -0
  2. data/.travis.yml +5 -0
  3. data/Gemfile +7 -0
  4. data/Guardfile +4 -0
  5. data/MIT-LICENSE +21 -0
  6. data/PROGRESS-NOTES.txt +5 -0
  7. data/README.markdown +152 -0
  8. data/Rakefile +35 -0
  9. data/THOUGHTS +84 -0
  10. data/doc/example/script-woodhouse +9 -0
  11. data/doc/example/woodhouse-initializer.rb +12 -0
  12. data/lib/generators/woodhouse_generator.rb +38 -0
  13. data/lib/woodhouse/dispatcher.rb +37 -0
  14. data/lib/woodhouse/dispatchers/bunny_dispatcher.rb +48 -0
  15. data/lib/woodhouse/dispatchers/common_amqp_dispatcher.rb +28 -0
  16. data/lib/woodhouse/dispatchers/hot_bunnies_dispatcher.rb +48 -0
  17. data/lib/woodhouse/dispatchers/local_dispatcher.rb +13 -0
  18. data/lib/woodhouse/dispatchers/local_pool_dispatcher.rb +25 -0
  19. data/lib/woodhouse/dispatchers.rb +19 -0
  20. data/lib/woodhouse/extension.rb +24 -0
  21. data/lib/woodhouse/extensions/new_relic/instrumentation_middleware.rb +10 -0
  22. data/lib/woodhouse/extensions/new_relic.rb +23 -0
  23. data/lib/woodhouse/extensions/progress.rb +165 -0
  24. data/lib/woodhouse/job.rb +76 -0
  25. data/lib/woodhouse/job_execution.rb +60 -0
  26. data/lib/woodhouse/layout.rb +290 -0
  27. data/lib/woodhouse/layout_builder.rb +55 -0
  28. data/lib/woodhouse/layout_serializer.rb +82 -0
  29. data/lib/woodhouse/middleware/airbrake_exceptions.rb +12 -0
  30. data/lib/woodhouse/middleware/assign_logger.rb +10 -0
  31. data/lib/woodhouse/middleware/log_dispatch.rb +21 -0
  32. data/lib/woodhouse/middleware/log_jobs.rb +22 -0
  33. data/lib/woodhouse/middleware.rb +16 -0
  34. data/lib/woodhouse/middleware_stack.rb +35 -0
  35. data/lib/woodhouse/mixin_registry.rb +27 -0
  36. data/lib/woodhouse/node_configuration.rb +80 -0
  37. data/lib/woodhouse/process.rb +41 -0
  38. data/lib/woodhouse/queue_criteria.rb +52 -0
  39. data/lib/woodhouse/rails.rb +46 -0
  40. data/lib/woodhouse/rails2.rb +21 -0
  41. data/lib/woodhouse/registry.rb +12 -0
  42. data/lib/woodhouse/runner.rb +60 -0
  43. data/lib/woodhouse/runners/bunny_runner.rb +60 -0
  44. data/lib/woodhouse/runners/dummy_runner.rb +11 -0
  45. data/lib/woodhouse/runners/hot_bunnies_runner.rb +79 -0
  46. data/lib/woodhouse/runners.rb +16 -0
  47. data/lib/woodhouse/scheduler.rb +113 -0
  48. data/lib/woodhouse/server.rb +80 -0
  49. data/lib/woodhouse/trigger_set.rb +19 -0
  50. data/lib/woodhouse/version.rb +3 -0
  51. data/lib/woodhouse/worker.rb +86 -0
  52. data/lib/woodhouse.rb +99 -0
  53. data/spec/integration/bunny_worker_process_spec.rb +32 -0
  54. data/spec/layout_builder_spec.rb +55 -0
  55. data/spec/layout_spec.rb +143 -0
  56. data/spec/middleware_stack_spec.rb +56 -0
  57. data/spec/mixin_registry_spec.rb +15 -0
  58. data/spec/node_configuration_spec.rb +22 -0
  59. data/spec/progress_spec.rb +40 -0
  60. data/spec/queue_criteria_spec.rb +11 -0
  61. data/spec/scheduler_spec.rb +41 -0
  62. data/spec/server_spec.rb +72 -0
  63. data/spec/shared_contexts.rb +70 -0
  64. data/spec/worker_spec.rb +28 -0
  65. data/woodhouse.gemspec +37 -0
  66. metadata +285 -0
@@ -0,0 +1,79 @@
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
+ client = HotBunnies.connect(@config.server_info)
23
+ channel = client.create_channel
24
+ channel.prefetch = 1
25
+ queue = channel.queue(@worker.queue_name)
26
+ exchange = channel.exchange(@worker.exchange_name, :type => :headers)
27
+ queue.bind(exchange, :arguments => @worker.criteria.amqp_headers)
28
+ worker = Celluloid.current_actor
29
+ queue.subscribe(:ack => true).each(:blocking => false) do |headers, payload|
30
+ begin
31
+ job = make_job(headers, payload)
32
+ if can_service_job?(job)
33
+ if service_job(job)
34
+ headers.ack
35
+ else
36
+ headers.reject
37
+ end
38
+ else
39
+ @config.logger.error("Cannot service job #{job.describe} in queue for #{@worker.describe}")
40
+ headers.reject
41
+ end
42
+ rescue => err
43
+ begin
44
+ @config.logger.error("Error bubbled up out of worker. This shouldn't happen. #{err.message}")
45
+ err.backtrace.each do |btr|
46
+ @config.logger.error(" #{btr}")
47
+ end
48
+ headers.reject
49
+ ensure
50
+ worker.bail_out(err)
51
+ end
52
+ end
53
+ end
54
+ wait :spin_down
55
+ ensure
56
+ client.close
57
+ end
58
+
59
+ def spin_down
60
+ signal :spin_down
61
+ end
62
+
63
+ def bail_out(err)
64
+ raise Woodhouse::BailOut, "#{err.class}: #{err.message}"
65
+ end
66
+
67
+ private
68
+
69
+ def make_job(message, payload)
70
+ Woodhouse::Job.new(@worker.worker_class_name, @worker.job_method) do |job|
71
+ job.arguments = message.properties.headers.inject({}) {|h,(k,v)|
72
+ h[k.to_s] = v.to_s
73
+ h
74
+ }
75
+ job.payload = payload
76
+ end
77
+ end
78
+
79
+ end
@@ -0,0 +1,16 @@
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'
@@ -0,0 +1,113 @@
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.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
@@ -0,0 +1,80 @@
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
@@ -0,0 +1,19 @@
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
@@ -0,0 +1,3 @@
1
+ module Woodhouse
2
+ VERSION = "0.1.1"
3
+ end
@@ -0,0 +1,86 @@
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
+ # You can dispatch a job +baz+ on class +FooBar+ by calling FooBar.async_baz.
72
+ def method_missing(method, *args, &block)
73
+ if method.to_s =~ /^asynch?_(.*)/
74
+ if instance_methods(false).detect{|meth| meth.to_s == $1 }
75
+ Woodhouse.dispatch(@worker_name, $1, args.first)
76
+ else
77
+ super
78
+ end
79
+ else
80
+ super
81
+ end
82
+ end
83
+
84
+ end
85
+
86
+ end
data/lib/woodhouse.rb ADDED
@@ -0,0 +1,99 @@
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 dispatch(*a)
62
+ global_configuration.dispatcher.dispatch(*a)
63
+ end
64
+
65
+ def update_job(*a)
66
+ global_configuration.dispatcher.update_job(*a)
67
+ end
68
+
69
+ end
70
+
71
+ extend GlobalMethods
72
+
73
+ end
74
+
75
+ require 'fiber18'
76
+ require 'celluloid'
77
+ require 'woodhouse/job'
78
+ require 'woodhouse/layout'
79
+ require 'woodhouse/layout_builder'
80
+ require 'woodhouse/scheduler'
81
+ require 'woodhouse/server'
82
+ require 'woodhouse/queue_criteria'
83
+ require 'woodhouse/node_configuration'
84
+ require 'woodhouse/registry'
85
+ require 'woodhouse/mixin_registry'
86
+ require 'woodhouse/worker'
87
+ require 'woodhouse/job_execution'
88
+ require 'woodhouse/runners'
89
+ require 'woodhouse/dispatchers'
90
+ require 'woodhouse/middleware_stack'
91
+ require 'woodhouse/middleware'
92
+ require 'woodhouse/rails'
93
+ require 'woodhouse/process'
94
+ require 'woodhouse/layout_serializer'
95
+ require 'woodhouse/trigger_set'
96
+
97
+ require 'woodhouse/extension'
98
+ require 'woodhouse/extensions/progress'
99
+ require 'woodhouse/extensions/new_relic'
@@ -0,0 +1,32 @@
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
@@ -0,0 +1,55 @@
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" }
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
+ odin = layout.node(:odin)
49
+ odin.workers.should have(2).workers
50
+ odin.workers.first.threads.should == 5
51
+
52
+ Woodhouse::Layout.load(layout.dump).dump.should == layout.dump
53
+ end
54
+
55
+ end