cloud-crowd 0.1.0

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/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