cloud-crowd 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. data/EPIGRAPHS +17 -0
  2. data/LICENSE +22 -0
  3. data/README +93 -0
  4. data/actions/graphics_magick.rb +43 -0
  5. data/actions/process_pdfs.rb +92 -0
  6. data/actions/word_count.rb +14 -0
  7. data/bin/crowd +5 -0
  8. data/cloud-crowd.gemspec +111 -0
  9. data/config/config.example.ru +17 -0
  10. data/config/config.example.yml +48 -0
  11. data/config/database.example.yml +9 -0
  12. data/examples/graphics_magick_example.rb +44 -0
  13. data/examples/process_pdfs_example.rb +40 -0
  14. data/examples/word_count_example.rb +41 -0
  15. data/lib/cloud-crowd.rb +130 -0
  16. data/lib/cloud_crowd/action.rb +101 -0
  17. data/lib/cloud_crowd/app.rb +117 -0
  18. data/lib/cloud_crowd/asset_store.rb +41 -0
  19. data/lib/cloud_crowd/asset_store/filesystem_store.rb +28 -0
  20. data/lib/cloud_crowd/asset_store/s3_store.rb +40 -0
  21. data/lib/cloud_crowd/command_line.rb +209 -0
  22. data/lib/cloud_crowd/daemon.rb +95 -0
  23. data/lib/cloud_crowd/exceptions.rb +28 -0
  24. data/lib/cloud_crowd/helpers.rb +8 -0
  25. data/lib/cloud_crowd/helpers/authorization.rb +50 -0
  26. data/lib/cloud_crowd/helpers/resources.rb +45 -0
  27. data/lib/cloud_crowd/inflector.rb +19 -0
  28. data/lib/cloud_crowd/models.rb +40 -0
  29. data/lib/cloud_crowd/models/job.rb +176 -0
  30. data/lib/cloud_crowd/models/work_unit.rb +89 -0
  31. data/lib/cloud_crowd/models/worker_record.rb +61 -0
  32. data/lib/cloud_crowd/runner.rb +15 -0
  33. data/lib/cloud_crowd/schema.rb +45 -0
  34. data/lib/cloud_crowd/worker.rb +186 -0
  35. data/public/css/admin_console.css +221 -0
  36. data/public/css/reset.css +42 -0
  37. data/public/images/bullet_green.png +0 -0
  38. data/public/images/bullet_white.png +0 -0
  39. data/public/images/cloud_hand.png +0 -0
  40. data/public/images/header_back.png +0 -0
  41. data/public/images/logo.png +0 -0
  42. data/public/images/queue_fill.png +0 -0
  43. data/public/images/server_error.png +0 -0
  44. data/public/images/sidebar_bottom.png +0 -0
  45. data/public/images/sidebar_top.png +0 -0
  46. data/public/images/worker_info.png +0 -0
  47. data/public/images/worker_info_loading.gif +0 -0
  48. data/public/js/admin_console.js +168 -0
  49. data/public/js/excanvas.js +1 -0
  50. data/public/js/flot.js +1 -0
  51. data/public/js/jquery.js +19 -0
  52. data/test/acceptance/test_app.rb +72 -0
  53. data/test/acceptance/test_failing_work_units.rb +32 -0
  54. data/test/acceptance/test_word_count.rb +49 -0
  55. data/test/blueprints.rb +17 -0
  56. data/test/config/actions/failure_testing.rb +13 -0
  57. data/test/config/config.ru +17 -0
  58. data/test/config/config.yml +7 -0
  59. data/test/config/database.yml +6 -0
  60. data/test/test_helper.rb +19 -0
  61. data/test/unit/test_action.rb +49 -0
  62. data/test/unit/test_configuration.rb +28 -0
  63. data/test/unit/test_job.rb +78 -0
  64. data/test/unit/test_work_unit.rb +55 -0
  65. data/views/index.erb +77 -0
  66. metadata +233 -0
@@ -0,0 +1,50 @@
1
+ module CloudCrowd
2
+ module Helpers
3
+
4
+ # Authorization takes after sinatra-authorization... See
5
+ # http://github.com/integrity/sinatra-authorization
6
+ # for the original.
7
+ module Authorization
8
+
9
+ # Ensure that the request includes the correct credentials.
10
+ def login_required
11
+ return if authorized?
12
+ unauthorized! unless auth.provided?
13
+ bad_request! unless auth.basic?
14
+ unauthorized! unless authorize(*auth.credentials)
15
+ request.env['REMOTE_USER'] = auth.username
16
+ end
17
+
18
+ # Has the request been authenticated?
19
+ def authorized?
20
+ !!request.env['REMOTE_USER']
21
+ end
22
+
23
+ # A request is authorized if its login and password match those stored
24
+ # in config.yml, or if authentication is disabled. If authentication is
25
+ # turned on, then every request is authenticated, including between
26
+ # the worker daemons and the central server.
27
+ def authorize(login, password)
28
+ return true unless CloudCrowd.config[:use_http_authentication]
29
+ return CloudCrowd.config[:login] == login &&
30
+ CloudCrowd.config[:password] == password
31
+ end
32
+
33
+
34
+ private
35
+
36
+ def auth
37
+ @auth ||= Rack::Auth::Basic::Request.new(request.env)
38
+ end
39
+
40
+ def unauthorized!(realm = App.authorization_realm)
41
+ response['WWW-Authenticate'] = "Basic realm=\"#{realm}\""
42
+ halt 401, 'Authorization Required'
43
+ end
44
+
45
+ def bad_request!
46
+ halt 400, 'Bad Request'
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,45 @@
1
+ module CloudCrowd
2
+ module Helpers
3
+ module Resources
4
+
5
+ # Convenience method for responding with JSON. Sets the content-type,
6
+ # serializes, and allows empty responses.
7
+ def json(obj)
8
+ content_type :json
9
+ return status(204) && '' if obj.nil?
10
+ obj.to_json
11
+ end
12
+
13
+ # Lazy-fetch the job specified by <tt>job_id</tt>.
14
+ def current_job
15
+ @job ||= Job.find_by_id(params[:job_id]) or raise Sinatra::NotFound
16
+ end
17
+
18
+ # Lazy-fetch the WorkUnit specified by <tt>work_unit_id</tt>.
19
+ def current_work_unit
20
+ @work_unit ||= WorkUnit.find_by_id(params[:work_unit_id]) or raise Sinatra::NotFound
21
+ end
22
+
23
+ # Try to fetch a work unit from the queue. If none are pending, respond
24
+ # with no content.
25
+ def dequeue_work_unit(offset=0)
26
+ handle_conflicts do
27
+ worker, actions = params[:worker_name], params[:worker_actions].split(',')
28
+ WorkUnit.dequeue(worker, actions, offset)
29
+ end
30
+ end
31
+
32
+ # We're using ActiveRecords optimistic locking, so stale work units
33
+ # may sometimes arise. handle_conflicts responds with a the HTTP status
34
+ # code of your choosing if the update failed to be applied.
35
+ def handle_conflicts(code=204)
36
+ begin
37
+ yield
38
+ rescue ActiveRecord::StaleObjectError => e
39
+ return status(code) && ''
40
+ end
41
+ end
42
+
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,19 @@
1
+ module CloudCrowd
2
+
3
+ # Pilfered in parts from the ActiveSupport::Inflector.
4
+ module Inflector
5
+
6
+ def self.camelize(word)
7
+ word.to_s.gsub(/\/(.?)/) { "::#{$1.upcase}" }.gsub(/(?:^|_)(.)/) { $1.upcase }
8
+ end
9
+
10
+ def self.underscore(word)
11
+ word.to_s.gsub(/::/, '/').
12
+ gsub(/([A-Z]+)([A-Z][a-z])/,'\1_\2').
13
+ gsub(/([a-z\d])([A-Z])/,'\1_\2').
14
+ tr("-", "_").
15
+ downcase
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,40 @@
1
+ module CloudCrowd
2
+
3
+ # Adds named scopes and query methods for every CloudCrowd status to
4
+ # both Jobs and WorkUnits.
5
+ module ModelStatus
6
+
7
+ def self.included(klass)
8
+
9
+ klass.class_eval do
10
+ # Note that COMPLETE and INCOMPLETE are unions of other states.
11
+ named_scope 'processing', :conditions => {:status => PROCESSING}
12
+ named_scope 'succeeded', :conditions => {:status => SUCCEEDED}
13
+ named_scope 'failed', :conditions => {:status => FAILED}
14
+ named_scope 'splitting', :conditions => {:status => SPLITTING}
15
+ named_scope 'merging', :conditions => {:status => MERGING}
16
+ named_scope 'complete', :conditions => {:status => COMPLETE}
17
+ named_scope 'incomplete', :conditions => {:status => INCOMPLETE}
18
+ end
19
+
20
+ end
21
+
22
+ def processing?; self.status == PROCESSING; end
23
+ def succeeded?; self.status == SUCCEEDED; end
24
+ def failed?; self.status == FAILED; end
25
+ def splitting?; self.status == SPLITTING; end
26
+ def merging?; self.status == MERGING; end
27
+ def complete?; COMPLETE.include?(self.status); end
28
+ def incomplete?; INCOMPLETE.include?(self.status); end
29
+
30
+ # Get the displayable status name of the model's status code.
31
+ def display_status
32
+ CloudCrowd.display_status(self.status)
33
+ end
34
+
35
+ end
36
+ end
37
+
38
+ require 'cloud_crowd/models/job'
39
+ require 'cloud_crowd/models/work_unit'
40
+ require 'cloud_crowd/models/worker_record'
@@ -0,0 +1,176 @@
1
+ module CloudCrowd
2
+
3
+ # A chunk of work that will be farmed out into many WorkUnits to be processed
4
+ # in parallel by each active CloudCrowd::Worker. Jobs are defined by a list
5
+ # of inputs (usually public urls to files), an action (the name of a script that
6
+ # CloudCrowd knows how to run), and, eventually a corresponding list of output.
7
+ class Job < ActiveRecord::Base
8
+ include ModelStatus
9
+
10
+ has_many :work_units, :dependent => :destroy
11
+
12
+ validates_presence_of :status, :inputs, :action, :options
13
+
14
+ before_validation_on_create :set_initial_status
15
+ after_create :queue_for_workers
16
+ before_destroy :cleanup_assets
17
+
18
+ # Create a Job from an incoming JSON or XML request, and add it to the queue.
19
+ # TODO: Think about XML support.
20
+ def self.create_from_request(h)
21
+ self.create(
22
+ :inputs => h['inputs'].to_json,
23
+ :action => h['action'],
24
+ :options => (h['options'] || {}).to_json,
25
+ :email => h['email'],
26
+ :callback_url => h['callback_url']
27
+ )
28
+ end
29
+
30
+ # After work units are marked successful, we check to see if all of them have
31
+ # finished, if so, continue on to the next phase of the job.
32
+ def check_for_completion
33
+ return unless all_work_units_complete?
34
+ transition_to_next_phase
35
+ output_list = gather_outputs_from_work_units
36
+
37
+ if complete?
38
+ self.outputs = output_list.to_json
39
+ self.time = Time.now - self.created_at
40
+ end
41
+ self.save
42
+
43
+ case self.status
44
+ when PROCESSING then queue_for_workers(output_list.map {|o| JSON.parse(o) }.flatten)
45
+ when MERGING then queue_for_workers(output_list.to_json)
46
+ else fire_callback
47
+ end
48
+ self
49
+ end
50
+
51
+ # If a <tt>callback_url</tt> is defined, post the Job's JSON to it upon
52
+ # completion. The <tt>callback_url</tt> may include HTTP basic authentication,
53
+ # if you like:
54
+ # http://user:password@example.com/job_complete
55
+ def fire_callback
56
+ begin
57
+ RestClient.post(callback_url, {:job => self.to_json}) if callback_url
58
+ rescue RestClient::Exception => e
59
+ puts "Failed to fire job callback. Hmmm, what should happen here?"
60
+ end
61
+ end
62
+
63
+ # Cleaning up after a job will remove all of its files from S3. Destroying
64
+ # a Job calls cleanup_assets first.
65
+ def cleanup_assets
66
+ AssetStore.new.cleanup(self)
67
+ end
68
+
69
+ # Have all of the WorkUnits finished?
70
+ #--
71
+ # We could trade reads for writes here
72
+ # by keeping a completed_count on the Job itself.
73
+ #++
74
+ def all_work_units_complete?
75
+ self.work_units.incomplete.count <= 0
76
+ end
77
+
78
+ # Have any of the WorkUnits failed?
79
+ def any_work_units_failed?
80
+ self.work_units.failed.count > 0
81
+ end
82
+
83
+ # This job is splittable if its Action has a +split+ method.
84
+ def splittable?
85
+ self.action_class.public_instance_methods.include? 'split'
86
+ end
87
+
88
+ # This job is mergeable if its Action has a +merge+ method.
89
+ def mergeable?
90
+ self.processing? && self.action_class.public_instance_methods.include?('merge')
91
+ end
92
+
93
+ # Retrieve the class for this Job's Action.
94
+ def action_class
95
+ klass = CloudCrowd.actions[self.action]
96
+ return klass if klass
97
+ raise Error::ActionNotFound, "no action named: '#{self.action}' could be found"
98
+ end
99
+
100
+ # How complete is this Job?
101
+ def percent_complete
102
+ return 0 if splitting?
103
+ return 100 if complete?
104
+ return 99 if merging?
105
+ (work_units.complete.count / work_units.count.to_f * 100).round
106
+ end
107
+
108
+ # How long has this Job taken?
109
+ def time_taken
110
+ return self.time if self.time
111
+ Time.now - self.created_at
112
+ end
113
+
114
+ # Generate a stable 8-bit Hex color code, based on the Job's id.
115
+ def color
116
+ @color ||= Digest::MD5.hexdigest(self.id.to_s)[-7...-1]
117
+ end
118
+
119
+ # A JSON representation of this job includes the statuses of its component
120
+ # WorkUnits, as well as any completed outputs.
121
+ def to_json(opts={})
122
+ atts = {
123
+ 'id' => id,
124
+ 'color' => color,
125
+ 'status' => display_status,
126
+ 'percent_complete' => percent_complete,
127
+ 'work_units' => work_units.count,
128
+ 'time_taken' => time_taken
129
+ }
130
+ atts['outputs'] = JSON.parse(outputs) if outputs
131
+ atts['email'] = email if email
132
+ atts.to_json
133
+ end
134
+
135
+
136
+ private
137
+
138
+ # When the WorkUnits are all finished, gather all their outputs together
139
+ # before removing them from the database entirely.
140
+ def gather_outputs_from_work_units
141
+ units = self.work_units.complete
142
+ outs = self.work_units.complete.map {|u| JSON.parse(u.output)['output'] }
143
+ self.work_units.complete.destroy_all
144
+ outs
145
+ end
146
+
147
+ # Transition this Job's status to the appropriate next status.
148
+ def transition_to_next_phase
149
+ self.status = any_work_units_failed? ? FAILED :
150
+ self.splitting? ? PROCESSING :
151
+ self.mergeable? ? MERGING :
152
+ SUCCEEDED
153
+ end
154
+
155
+ # When starting a new job, or moving to a new stage, split up the inputs
156
+ # into WorkUnits, and queue them. Workers will start picking them up right
157
+ # away.
158
+ def queue_for_workers(input=nil)
159
+ input ||= JSON.parse(self.inputs)
160
+ [input].flatten.each do |wu_input|
161
+ WorkUnit.create(
162
+ :job => self,
163
+ :action => self.action,
164
+ :input => wu_input,
165
+ :status => self.status
166
+ )
167
+ end
168
+ end
169
+
170
+ # A Job starts out either splitting or processing, depending on its action.
171
+ def set_initial_status
172
+ self.status = self.splittable? ? SPLITTING : PROCESSING
173
+ end
174
+
175
+ end
176
+ end
@@ -0,0 +1,89 @@
1
+ module CloudCrowd
2
+
3
+ # A WorkUnit is an atomic chunk of work from a job, processing a single input
4
+ # through a single action. The WorkUnits are run in parallel, with each worker
5
+ # daemon processing one at a time. The splitting and merging stages of a job
6
+ # are each run as a single WorkUnit.
7
+ class WorkUnit < ActiveRecord::Base
8
+ include ModelStatus
9
+
10
+ belongs_to :job
11
+ belongs_to :worker_record
12
+
13
+ validates_presence_of :job_id, :status, :input, :action
14
+
15
+ after_save :check_for_job_completion
16
+
17
+ # Find the first available WorkUnit in the queue, and take it out.
18
+ # +enabled_actions+ must be passed to whitelist the types of WorkUnits than
19
+ # can be retrieved for processing. Optionally, specify the +offset+ to peek
20
+ # further on in line.
21
+ def self.dequeue(worker_name, enabled_actions=[], offset=0)
22
+ unit = self.first(
23
+ :conditions => {:status => INCOMPLETE, :worker_record_id => nil, :action => enabled_actions},
24
+ :order => "created_at asc",
25
+ :offset => offset
26
+ )
27
+ unit ? unit.assign_to(worker_name) : nil
28
+ end
29
+
30
+ # After saving a WorkUnit, its Job should check if it just became complete.
31
+ def check_for_job_completion
32
+ self.job.check_for_completion if complete?
33
+ end
34
+
35
+ # Mark this unit as having finished successfully.
36
+ def finish(output, time_taken)
37
+ update_attributes({
38
+ :status => SUCCEEDED,
39
+ :worker_record => nil,
40
+ :attempts => self.attempts + 1,
41
+ :output => output,
42
+ :time => time_taken
43
+ })
44
+ end
45
+
46
+ # Mark this unit as having failed. May attempt a retry.
47
+ def fail(output, time_taken)
48
+ tries = self.attempts + 1
49
+ return try_again if tries < CloudCrowd.config[:work_unit_retries]
50
+ update_attributes({
51
+ :status => FAILED,
52
+ :worker_record => nil,
53
+ :attempts => tries,
54
+ :output => output,
55
+ :time => time_taken
56
+ })
57
+ end
58
+
59
+ # Ever tried. Ever failed. No matter. Try again. Fail again. Fail better.
60
+ def try_again
61
+ update_attributes({
62
+ :worker_record => nil,
63
+ :attempts => self.attempts + 1
64
+ })
65
+ end
66
+
67
+ # When a Worker checks out a WorkUnit, establish the connection between
68
+ # WorkUnit and WorkerRecord.
69
+ def assign_to(worker_name)
70
+ self.worker_record = WorkerRecord.find_by_name!(worker_name)
71
+ self.save ? self : nil
72
+ end
73
+
74
+ # The JSON representation of a WorkUnit shares the Job's options with all
75
+ # its sister WorkUnits.
76
+ def to_json
77
+ {
78
+ 'id' => self.id,
79
+ 'job_id' => self.job_id,
80
+ 'input' => self.input,
81
+ 'attempts' => self.attempts,
82
+ 'action' => self.action,
83
+ 'options' => JSON.parse(self.job.options),
84
+ 'status' => self.status
85
+ }.to_json
86
+ end
87
+
88
+ end
89
+ end
@@ -0,0 +1,61 @@
1
+ module CloudCrowd
2
+
3
+ # A WorkerRecord is a recording of an active worker daemon running remotely.
4
+ # Every time it checks in, we keep track of its status. The attributes shown
5
+ # here may lag their actual values by up to Worker::CHECK_IN_INTERVAL seconds.
6
+ class WorkerRecord < ActiveRecord::Base
7
+
8
+ EXPIRES_AFTER = 2 * Worker::CHECK_IN_INTERVAL
9
+
10
+ has_one :work_unit
11
+
12
+ validates_presence_of :name, :thread_status
13
+
14
+ before_destroy :clear_work_units
15
+
16
+ named_scope :alive, lambda { {:conditions => ['updated_at > ?', Time.now - EXPIRES_AFTER]} }
17
+ named_scope :dead, lambda { {:conditions => ['updated_at <= ?', Time.now - EXPIRES_AFTER]} }
18
+
19
+ # Save a Worker's current status to the database.
20
+ def self.check_in(params)
21
+ attrs = {:thread_status => params[:thread_status], :updated_at => Time.now}
22
+ self.find_or_create_by_name(params[:name]).update_attributes!(attrs)
23
+ end
24
+
25
+ # Remove a terminated Worker's record from the database.
26
+ def self.check_out(params)
27
+ self.find_by_name(params[:name]).destroy
28
+ end
29
+
30
+ # We consider the worker to be alive if it's checked in more recently
31
+ # than twice the expected interval ago.
32
+ def alive?
33
+ updated_at > Time.now - EXPIRES_AFTER
34
+ end
35
+
36
+ # Derive the Worker's PID on the remote machine from the name.
37
+ def pid
38
+ @pid ||= self.name.split('@').first
39
+ end
40
+
41
+ # Derive the hostname from the Worker's name.
42
+ def hostname
43
+ @hostname ||= self.name.split('@').last
44
+ end
45
+
46
+ def to_json(opts={})
47
+ {
48
+ 'name' => name,
49
+ 'status' => work_unit && work_unit.display_status,
50
+ }.to_json
51
+ end
52
+
53
+
54
+ private
55
+
56
+ def clear_work_units
57
+ WorkUnit.update_all('worker_record_id = null', "worker_record_id = #{id}")
58
+ end
59
+
60
+ end
61
+ end