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,82 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,22 @@
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
@@ -0,0 +1,16 @@
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'
@@ -0,0 +1,35 @@
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
@@ -0,0 +1,27 @@
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
@@ -0,0 +1,80 @@
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
+ private
50
+
51
+ def lookup_key(key, namespace)
52
+ const = Woodhouse.const_get("#{namespace}s").const_get("#{camelize(key.to_s)}#{namespace}")
53
+ unless const
54
+ raise NameError, "couldn't find Woodhouse::#{namespace}s::#{camelize(key.to_s)}#{namespace} (from #{key})"
55
+ end
56
+ const
57
+ end
58
+
59
+ def symbolize_keys(hash)
60
+ hash.inject({}){|h,(k,v)|
61
+ h[k.to_sym] = v
62
+ h
63
+ }
64
+ end
65
+
66
+ # TODO: detect defaults based on platform
67
+ def self.default
68
+ new do |config|
69
+ config.registry = Woodhouse::MixinRegistry.new
70
+ config.server_info = nil
71
+ config.runner_type = Woodhouse::Runners.guess
72
+ config.dispatcher_type = :local
73
+ config.logger = Logger.new("/dev/null")
74
+ config.dispatcher_middleware << Woodhouse::Middleware::LogDispatch
75
+ config.runner_middleware << Woodhouse::Middleware::LogJobs
76
+ config.runner_middleware << Woodhouse::Middleware::AssignLogger
77
+ end
78
+ end
79
+
80
+ end
@@ -0,0 +1,41 @@
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
+ end
7
+
8
+ def execute
9
+ # Borrowed this from sidekiq. https://github.com/mperham/sidekiq/blob/master/lib/sidekiq/cli.rb
10
+ trap "INT" do
11
+ Thread.main.raise Interrupt
12
+ end
13
+
14
+ trap "TERM" do
15
+ Thread.main.raise Interrupt
16
+ end
17
+
18
+ begin
19
+ @server.start!
20
+ puts "Woodhouse serving as of #{Time.now}. Ctrl-C to stop."
21
+ @server.wait(:shutdown)
22
+ rescue Interrupt
23
+ puts "Shutting down."
24
+ @server.shutdown!
25
+ @server.wait(:shutdown)
26
+ ensure
27
+ @server.terminate
28
+ exit
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def build_default_server(keyw)
35
+ Woodhouse::Server.new(
36
+ :layout => keyw[:layout] || Woodhouse.global_layout,
37
+ :node => keyw[:node] || :default
38
+ )
39
+ end
40
+
41
+ end
@@ -0,0 +1,52 @@
1
+ module Woodhouse
2
+
3
+ class QueueCriteria
4
+ attr_reader :criteria
5
+
6
+ def initialize(opts = {})
7
+ if opts.kind_of?(self.class)
8
+ opts = opts.criteria
9
+ end
10
+ unless opts.nil?
11
+ @criteria = stringify_values(opts).freeze
12
+ end
13
+ end
14
+
15
+ def ==(other)
16
+ @criteria == other.criteria
17
+ end
18
+
19
+ def describe
20
+ @criteria.inspect
21
+ end
22
+
23
+ def amqp_headers
24
+ # TODO: needs to be smarter
25
+ @criteria ? @criteria.merge('x-match' => 'all') : {}
26
+ end
27
+
28
+ def queue_key
29
+ @criteria ? @criteria.map{|k,v|
30
+ "#{k.downcase}_#{v.downcase}"
31
+ }.join("_") : ""
32
+ end
33
+
34
+ def matches?(args)
35
+ return true if @criteria.nil?
36
+ @criteria.all? do |key, val|
37
+ args[key] == val
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ def stringify_values(hash)
44
+ hash.inject({}) {|h,(k,v)|
45
+ h[k.to_s] = v.to_s
46
+ h
47
+ }
48
+ end
49
+
50
+ end
51
+
52
+ end
@@ -0,0 +1,46 @@
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
+ # config.autoload_paths << Rails.root.join("app/workers")
23
+
24
+ initializer 'woodhouse' do
25
+ config_paths = %w[woodhouse.yml workling.yml].map{|file|
26
+ Rails.root.join("config/" + file)
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
+ # Set up reasonable defaults
35
+ Woodhouse.configure do |config|
36
+ config.logger = ::Rails.logger
37
+ config_paths.each do |path|
38
+ if File.exist?(path)
39
+ config.server_info = YAML.load(File.read(path))[::Rails.env]
40
+ end
41
+ end
42
+ end
43
+ Woodhouse.finish_loading_layout!
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,12 @@
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
@@ -0,0 +1,60 @@
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
+ @config.logger.debug "Thread for #{@worker.describe} ready and waiting for jobs"
31
+ end
32
+
33
+ # Implement this in a subclass. When this message is received by an actor, it should
34
+ # finish whatever job it is currently doing, gracefully disconnect from AMQP, and
35
+ # stop the subscribe loop.
36
+ def spin_down
37
+ raise NotImplementedError, "implement #spin_down in a subclass of Woodhouse::Runner"
38
+ end
39
+
40
+ private
41
+
42
+ # Implement this in a subclass. When this message is received by an actor, it should
43
+ # connect to AMQP and start pulling jobs off the queue. This method should not finish
44
+ # until spin_down is called.
45
+ def subscribe # :doc:
46
+ raise NotImplementedError, "implement #subscribe in a subclass of Woodhouse::Runner"
47
+ end
48
+
49
+ # Returns +true+ if the Job's arguments match this worker's QueueCriteria, else +false+.
50
+ def can_service_job?(job) # :doc:
51
+ @worker.accepts_job?(job)
52
+ end
53
+
54
+ # Executes a Job. See Woodhouse::JobExecution.
55
+ def service_job(job) # :doc:
56
+ @config.logger.debug "Servicing job for #{@worker.describe}"
57
+ Woodhouse::JobExecution.new(@config, job).execute
58
+ end
59
+
60
+ end
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,11 @@
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