woodhouse 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,165 @@
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, :durable => true)
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
+ Celluloid::Future.new { progress_sink.update_job(job, data) }
146
+ end
147
+
148
+ def progress_sink
149
+ @progress_sink ||= Woodhouse
150
+ end
151
+
152
+ end
153
+
154
+ class InjectProgress < Woodhouse::Middleware
155
+
156
+ def call(job, worker)
157
+ job.extend JobWithProgress
158
+ yield job, worker
159
+ end
160
+
161
+ end
162
+
163
+ end
164
+
165
+ Woodhouse::Extension.register :progress, Woodhouse::Progress
@@ -0,0 +1,76 @@
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
@@ -0,0 +1,60 @@
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
@@ -0,0 +1,290 @@
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.public_instance_methods(false).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
+
192
+ def initialize(worker_class_name, job_method, opts = {})
193
+ opts = opts.clone
194
+ self.worker_class_name = worker_class_name
195
+ self.job_method = job_method
196
+ self.threads = opts.delete(:threads) || 1
197
+ self.criteria = opts.delete(:only)
198
+ unless opts.keys.empty?
199
+ raise ArgumentError, "unknown option keys: #{opts.keys.inspect}"
200
+ end
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).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
@@ -0,0 +1,55 @@
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