rocketjob 1.0.0 → 1.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.
@@ -1,4 +1,4 @@
1
1
  # encoding: UTF-8
2
2
  module RocketJob #:nodoc
3
- VERSION = "1.0.0"
3
+ VERSION = '1.1.0'
4
4
  end
@@ -36,6 +36,7 @@ module RocketJob
36
36
  # Prevent data in MongoDB from re-defining the model behavior
37
37
  #self.static_keys = true
38
38
 
39
+ # @formatter:off
39
40
  # Unique Name of this worker instance
40
41
  # Defaults to the `hostname` but _must_ be overriden if mutiple Worker instances
41
42
  # are started on the same host
@@ -86,6 +87,7 @@ module RocketJob
86
87
  transitions from: :starting, to: :stopping
87
88
  end
88
89
  end
90
+ # @formatter:on
89
91
 
90
92
  attr_reader :thread_pool
91
93
 
@@ -100,7 +102,7 @@ module RocketJob
100
102
  worker.save!
101
103
  create_indexes
102
104
  register_signal_handlers
103
- raise "The RocketJob configuration is being applied after the system has been initialized" unless RocketJob::Job.database.name == RocketJob::SlicedJob.database.name
105
+ raise 'The RocketJob configuration is being applied after the system has been initialized' unless RocketJob::Job.database.name == RocketJob::SlicedJob.database.name
104
106
  logger.info "Using MongoDB Database: #{RocketJob::Job.database.name}"
105
107
  worker.run
106
108
  end
@@ -112,37 +114,34 @@ module RocketJob
112
114
  Job.create_indexes
113
115
  end
114
116
 
115
- # Destroy dead workers ( missed at least the last 4 heartbeats )
116
- # Requeue jobs assigned to dead workers
117
- # Destroy dead workers
118
- def self.destroy_dead_workers
119
- dead_seconds = Config.instance.heartbeat_seconds * 4
117
+ # Destroy's all instances of zombie workers and requeues any jobs still "running"
118
+ # on those workers
119
+ def self.destroy_zombies
120
120
  each do |worker|
121
- next if (Time.now - worker.heartbeat.updated_at) < dead_seconds
122
- logger.warn "Destroying worker #{worker.name}, and requeueing its jobs"
121
+ next unless zombie?
122
+ logger.warn "Destroying zombie worker #{worker.name}, and requeueing its jobs"
123
123
  worker.destroy
124
124
  end
125
125
  end
126
126
 
127
+ def self.destroy_dead_workers
128
+ warn 'RocketJob::Worker.destroy_dead_workers is deprecated, use RocketJob::Worker.destroy_zombies'
129
+ destroy_zombies
130
+ end
131
+
127
132
  # Stop all running, paused, or starting workers
128
133
  def self.stop_all
129
- where(state: ['running', 'paused', 'starting']).each { |worker| worker.stop! }
134
+ where(state: [:running, :paused, :starting]).each(&:stop!)
130
135
  end
131
136
 
132
137
  # Pause all running workers
133
138
  def self.pause_all
134
- where(state: 'running').each { |worker| worker.pause! }
139
+ running.each(&:pause!)
135
140
  end
136
141
 
137
142
  # Resume all paused workers
138
143
  def self.resume_all
139
- each { |worker| worker.resume! if worker.paused? }
140
- end
141
-
142
- # Register a handler to perform cleanups etc. whenever a worker is
143
- # explicitly destroyed
144
- def self.register_destroy_handler(&block)
145
- @@destroy_handlers << block
144
+ paused.each(&:resume!)
146
145
  end
147
146
 
148
147
  # Returns [Boolean] whether the worker is shutting down
@@ -162,7 +161,7 @@ module RocketJob
162
161
 
163
162
  # Run this instance of the worker
164
163
  def run
165
- Thread.current.name = 'RocketJob main'
164
+ Thread.current.name = 'rocketjob main'
166
165
  build_heartbeat unless heartbeat
167
166
 
168
167
  started
@@ -211,7 +210,18 @@ module RocketJob
211
210
  end
212
211
 
213
212
  def thread_pool_count
214
- thread_pool.count{ |i| i.alive? }
213
+ thread_pool.count { |i| i.alive? }
214
+ end
215
+
216
+ # Returns [true|false] if this worker has missed at least the last 4 heartbeats
217
+ #
218
+ # Possible causes for a worker to miss its heartbeats:
219
+ # - The worker process has died
220
+ # - The worker process is "hanging"
221
+ # - The worker is no longer able to communicate with the MongoDB Server
222
+ def zombie?(missed = 4)
223
+ dead_seconds = Config.instance.heartbeat_seconds * missed
224
+ (Time.now - worker.heartbeat.updated_at) >= dead_seconds
215
225
  end
216
226
 
217
227
  protected
@@ -279,7 +289,7 @@ module RocketJob
279
289
  # Process the next available job
280
290
  # Returns [Boolean] whether any job was actually processed
281
291
  def process_next_job
282
- skip_job_ids = []
292
+ skip_job_ids = []
283
293
  while job = Job.next_job(name, skip_job_ids)
284
294
  logger.tagged("Job #{job.id}") do
285
295
  if job.work(self)
@@ -297,7 +307,7 @@ module RocketJob
297
307
  # Requeue any jobs assigned to this worker
298
308
  def requeue_jobs
299
309
  stop! if running? || paused?
300
- @@destroy_handlers.each { |handler| handler.call(name) }
310
+ RocketJob::Job.requeue_dead_worker(name)
301
311
  end
302
312
 
303
313
  # Mutex protected shutdown indicator
@@ -311,25 +321,28 @@ module RocketJob
311
321
  #
312
322
  def self.register_signal_handlers
313
323
  begin
314
- Signal.trap "SIGTERM" do
324
+ Signal.trap 'SIGTERM' do
315
325
  # Cannot use Mutex protected writer here since it is in a signal handler
316
326
  @@shutdown = true
317
- logger.warn "Shutdown signal (SIGTERM) received. Will shutdown as soon as active jobs/slices have completed."
327
+ logger.warn 'Shutdown signal (SIGTERM) received. Will shutdown as soon as active jobs/slices have completed.'
318
328
  end
319
329
 
320
- Signal.trap "INT" do
330
+ Signal.trap 'INT' do
321
331
  # Cannot use Mutex protected writer here since it is in a signal handler
322
332
  @@shutdown = true
323
- logger.warn "Shutdown signal (INT) received. Will shutdown as soon as active jobs/slices have completed."
333
+ logger.warn 'Shutdown signal (INT) received. Will shutdown as soon as active jobs/slices have completed.'
324
334
  end
325
- rescue Exception
326
- logger.warn "SIGTERM handler not installed. Not able to shutdown gracefully"
335
+ rescue StandardError
336
+ logger.warn 'SIGTERM handler not installed. Not able to shutdown gracefully'
327
337
  end
328
338
  end
329
339
 
330
340
  # Patch the way MongoMapper reloads a model
331
341
  def reload
332
342
  if doc = collection.find_one(:_id => id)
343
+ # Clear out keys that are not returned during the reload from MongoDB
344
+ (keys.keys - doc.keys).each { |key| send("#{key}=", nil) }
345
+ initialize_default_values
333
346
  load_from_database(doc)
334
347
  self
335
348
  else
@@ -337,10 +350,6 @@ module RocketJob
337
350
  end
338
351
  end
339
352
 
340
- private
341
-
342
- @@destroy_handlers = ThreadSafe::Array.new
343
-
344
353
  end
345
354
  end
346
355
 
data/lib/rocketjob.rb CHANGED
@@ -5,18 +5,35 @@ require 'mongo_mapper'
5
5
  require 'semantic_logger'
6
6
  require 'rocket_job/version'
7
7
 
8
+ # @formatter:off
8
9
  module RocketJob
9
- autoload :CLI, 'rocket_job/cli'
10
- autoload :Config, 'rocket_job/config'
11
- autoload :DirmonEntry, 'rocket_job/dirmon_entry'
12
- autoload :Heartbeat, 'rocket_job/heartbeat'
13
- autoload :Job, 'rocket_job/job'
14
- autoload :JobException, 'rocket_job/job_exception'
15
- autoload :Worker, 'rocket_job/worker'
10
+ autoload :CLI, 'rocket_job/cli'
11
+ autoload :Config, 'rocket_job/config'
12
+ autoload :DirmonEntry, 'rocket_job/dirmon_entry'
13
+ autoload :Heartbeat, 'rocket_job/heartbeat'
14
+ autoload :Job, 'rocket_job/job'
15
+ autoload :JobException, 'rocket_job/job_exception'
16
+ autoload :Worker, 'rocket_job/worker'
16
17
  module Concerns
17
- autoload :Worker, 'rocket_job/concerns/worker'
18
+ autoload :Worker, 'rocket_job/concerns/worker'
19
+ autoload :Singleton, 'rocket_job/concerns/singleton'
18
20
  end
19
21
  module Jobs
20
- autoload :DirmonJob, 'rocket_job/jobs/dirmon_job'
22
+ autoload :DirmonJob, 'rocket_job/jobs/dirmon_job'
23
+ end
24
+
25
+ # @formatter:on
26
+ # Returns a human readable duration from the supplied [Float] number of seconds
27
+ def self.seconds_as_duration(seconds)
28
+ time = Time.at(seconds)
29
+ if seconds >= 1.day
30
+ "#{(seconds / 1.day).to_i}d #{time.strftime('%-Hh %-Mm %-Ss')}"
31
+ elsif seconds >= 1.hour
32
+ time.strftime('%-Hh %-Mm %-Ss')
33
+ elsif seconds >= 1.minute
34
+ time.strftime('%-Mm %-Ss')
35
+ else
36
+ time.strftime('%-Ss')
37
+ end
21
38
  end
22
39
  end
@@ -4,67 +4,233 @@ require_relative 'jobs/test_job'
4
4
  # Unit Test for RocketJob::Job
5
5
  class DirmonEntryTest < Minitest::Test
6
6
  context RocketJob::DirmonEntry do
7
- teardown do
8
- @dirmon_entry.destroy if @dirmon_entry && @dirmon_entry.new_record?
9
- end
10
-
11
7
  context '.config' do
12
8
  should 'support multiple databases' do
13
9
  assert_equal 'test_rocketjob', RocketJob::DirmonEntry.collection.db.name
14
10
  end
15
11
  end
16
12
 
13
+ context '#job_class' do
14
+ context 'with a nil job_class_name' do
15
+ should 'return nil' do
16
+ entry = RocketJob::DirmonEntry.new
17
+ assert_equal(nil, entry.job_class)
18
+ end
19
+ end
20
+
21
+ context 'with an unknown job_class_name' do
22
+ should 'return nil' do
23
+ entry = RocketJob::DirmonEntry.new(job_class_name: 'FakeJobThatDoesNotExistAnyWhereIPromise')
24
+ assert_equal(nil, entry.job_class)
25
+ end
26
+ end
27
+
28
+ context 'with a valid job_class_name' do
29
+ should 'return job class' do
30
+ entry = RocketJob::DirmonEntry.new(job_class_name: 'RocketJob::Job')
31
+ assert_equal(RocketJob::Job, entry.job_class)
32
+ end
33
+ end
34
+ end
35
+
36
+ context '.whitelist_paths' do
37
+ should 'default to []' do
38
+ assert_equal [], RocketJob::DirmonEntry.whitelist_paths
39
+ end
40
+ end
41
+
42
+ context '.add_whitelist_path' do
43
+ teardown do
44
+ RocketJob::DirmonEntry.whitelist_paths.each { |path| RocketJob::DirmonEntry.delete_whitelist_path(path) }
45
+ end
46
+
47
+ should 'convert relative path to an absolute one' do
48
+ path = Pathname('test/jobs').realpath.to_s
49
+ assert_equal path, RocketJob::DirmonEntry.add_whitelist_path('test/jobs')
50
+ assert_equal [path], RocketJob::DirmonEntry.whitelist_paths
51
+ end
52
+
53
+ should 'prevent duplicates' do
54
+ path = Pathname('test/jobs').realpath.to_s
55
+ assert_equal path, RocketJob::DirmonEntry.add_whitelist_path('test/jobs')
56
+ assert_equal path, RocketJob::DirmonEntry.add_whitelist_path('test/jobs')
57
+ assert_equal path, RocketJob::DirmonEntry.add_whitelist_path(path)
58
+ assert_equal [path], RocketJob::DirmonEntry.whitelist_paths
59
+ end
60
+ end
61
+
62
+ context '#fail_with_exception!' do
63
+ setup do
64
+ @dirmon_entry = RocketJob::DirmonEntry.new(job_class_name: 'Jobs::TestJob', pattern: '/abc/**', arguments: [1])
65
+ @dirmon_entry.enable!
66
+ end
67
+ teardown do
68
+ @dirmon_entry.destroy if @dirmon_entry && @dirmon_entry.new_record?
69
+ end
70
+
71
+ should 'fail with message' do
72
+ @dirmon_entry.fail_with_exception!('myworker:2323', 'oh no')
73
+ assert_equal true, @dirmon_entry.failed?
74
+ assert_equal 'RocketJob::DirmonEntryException', @dirmon_entry.exception.class_name
75
+ assert_equal 'oh no', @dirmon_entry.exception.message
76
+ end
77
+
78
+ should 'fail with exception' do
79
+ exception = nil
80
+ begin
81
+ blah
82
+ rescue Exception => exc
83
+ exception = exc
84
+ end
85
+ @dirmon_entry.fail_with_exception!('myworker:2323', exception)
86
+
87
+ assert_equal true, @dirmon_entry.failed?
88
+ assert_equal exception.class.name.to_s, @dirmon_entry.exception.class_name
89
+ assert @dirmon_entry.exception.message.include?('undefined local variable or method'), @dirmon_entry.attributes.inspect
90
+ end
91
+ end
92
+
17
93
  context '#validate' do
18
94
  should 'existance' do
19
- assert entry = RocketJob::DirmonEntry.new(job_name: 'Jobs::TestJob')
95
+ assert entry = RocketJob::DirmonEntry.new(job_class_name: 'Jobs::TestJob')
20
96
  assert_equal false, entry.valid?
21
- assert_equal [ "can't be blank" ], entry.errors[:path], entry.errors.inspect
97
+ assert_equal ["can't be blank"], entry.errors[:pattern], entry.errors.inspect
22
98
  end
23
99
 
24
- should 'job_name' do
25
- assert entry = RocketJob::DirmonEntry.new(path: '/abc/**')
26
- assert_equal false, entry.valid?
27
- assert_equal ["can't be blank", "job_name must be defined and must be derived from RocketJob::Job"], entry.errors[:job_name], entry.errors.inspect
100
+ context 'job_class_name' do
101
+ should 'ensure presence' do
102
+ assert entry = RocketJob::DirmonEntry.new(pattern: '/abc/**')
103
+ assert_equal false, entry.valid?
104
+ assert_equal ["can't be blank", 'job_class_name must be defined and must be derived from RocketJob::Job'], entry.errors[:job_class_name], entry.errors.inspect
105
+ end
28
106
  end
29
107
 
30
- should 'arguments' do
31
- assert entry = RocketJob::DirmonEntry.new(
32
- job_name: 'Jobs::TestJob',
33
- path: '/abc/**'
34
- )
35
- assert_equal false, entry.valid?
36
- assert_equal ["There must be 1 argument(s)"], entry.errors[:arguments], entry.errors.inspect
108
+ context 'arguments' do
109
+ should 'ensure correct number of arguments' do
110
+ assert entry = RocketJob::DirmonEntry.new(
111
+ job_class_name: 'Jobs::TestJob',
112
+ pattern: '/abc/**'
113
+ )
114
+ assert_equal false, entry.valid?
115
+ assert_equal ['There must be 1 argument(s)'], entry.errors[:arguments], entry.errors.inspect
116
+ end
117
+
118
+ should 'return false if the job name is bad' do
119
+ assert entry = RocketJob::DirmonEntry.new(
120
+ job_class_name: 'Jobs::Tests::Names::Things',
121
+ pattern: '/abc/**'
122
+ )
123
+ assert_equal false, entry.valid?
124
+ assert_equal [], entry.errors[:arguments], entry.errors.inspect
125
+ end
37
126
  end
38
127
 
39
128
  should 'arguments with perform_method' do
40
129
  assert entry = RocketJob::DirmonEntry.new(
41
- job_name: 'Jobs::TestJob',
42
- path: '/abc/**',
43
- perform_method: :sum
44
- )
130
+ job_class_name: 'Jobs::TestJob',
131
+ pattern: '/abc/**',
132
+ perform_method: :sum
133
+ )
45
134
  assert_equal false, entry.valid?
46
- assert_equal ["There must be 2 argument(s)"], entry.errors[:arguments], entry.errors.inspect
135
+ assert_equal ['There must be 2 argument(s)'], entry.errors[:arguments], entry.errors.inspect
47
136
  end
48
137
 
49
138
  should 'valid' do
50
139
  assert entry = RocketJob::DirmonEntry.new(
51
- job_name: 'Jobs::TestJob',
52
- path: '/abc/**',
53
- arguments: [1]
54
- )
140
+ job_class_name: 'Jobs::TestJob',
141
+ pattern: '/abc/**',
142
+ arguments: [1]
143
+ )
55
144
  assert entry.valid?, entry.errors.inspect
56
145
  end
57
146
 
58
147
  should 'valid with perform_method' do
59
148
  assert entry = RocketJob::DirmonEntry.new(
60
- job_name: 'Jobs::TestJob',
61
- path: '/abc/**',
62
- perform_method: :sum,
63
- arguments: [1,2]
64
- )
149
+ job_class_name: 'Jobs::TestJob',
150
+ pattern: '/abc/**',
151
+ perform_method: :sum,
152
+ arguments: [1, 2]
153
+ )
65
154
  assert entry.valid?, entry.errors.inspect
66
155
  end
67
156
  end
68
157
 
158
+ context 'with valid entry' do
159
+ setup do
160
+ @archive_directory = '/tmp/archive_directory'
161
+ @entry = RocketJob::DirmonEntry.new(
162
+ pattern: 'abc/*',
163
+ job_class_name: 'Jobs::TestJob',
164
+ arguments: [{input: 'yes'}],
165
+ properties: {priority: 23, perform_method: :event},
166
+ archive_directory: @archive_directory
167
+ )
168
+ @job = Jobs::TestJob.new
169
+ @file = Tempfile.new('archive')
170
+ @file_name = @file.path
171
+ @pathname = Pathname.new(@file_name)
172
+ File.open(@file_name, 'w') { |file| file.write('Hello World') }
173
+ assert File.exists?(@file_name)
174
+ @archive_file_name = File.join(@archive_directory, "#{@job.id}_#{File.basename(@file_name)}")
175
+ end
176
+
177
+ teardown do
178
+ @file.delete if @file
179
+ end
180
+
181
+ context '#archive_pathname' do
182
+ should 'with archive directory' do
183
+ assert_equal @archive_directory.to_s, @entry.archive_pathname.to_s
184
+ end
185
+
186
+ should 'without archive directory' do
187
+ @entry.archive_directory = nil
188
+ assert_equal '_archive', @entry.archive_pathname.to_s
189
+ end
190
+ end
191
+
192
+ context '#archive_file' do
193
+ should 'archive file' do
194
+ assert_equal @archive_file_name, @entry.send(:archive_file, @job, Pathname.new(@file_name))
195
+ assert File.exists?(@archive_file_name), @archive_file_name
196
+ end
197
+ end
198
+
199
+ context '#upload_default' do
200
+ should 'upload' do
201
+ @entry.send(:upload_default, @job, @pathname)
202
+ assert_equal File.absolute_path(@archive_file_name), @job.arguments.first[:full_file_name], @job.arguments
203
+ end
204
+ end
205
+
206
+ context '#upload_file' do
207
+ should 'upload using #file_store_upload' do
208
+ @job.define_singleton_method(:file_store_upload) do |file_name|
209
+ self.description = "FILE:#{file_name}"
210
+ end
211
+ @entry.send(:upload_file, @job, @pathname)
212
+ assert_equal "FILE:#{@file_name}", @job.description
213
+ end
214
+
215
+ should 'upload using #upload' do
216
+ @job.define_singleton_method(:upload) do |file_name|
217
+ self.description = "FILE:#{file_name}"
218
+ end
219
+ @entry.send(:upload_file, @job, @pathname)
220
+ assert_equal "FILE:#{@file_name}", @job.description
221
+ end
222
+ end
223
+
224
+ context '#later' do
225
+ should 'enqueue job' do
226
+ @entry.arguments = [{}]
227
+ @entry.perform_method = :event
228
+ job = @entry.later(@pathname)
229
+ assert_equal File.join(@archive_directory, "#{job.id}_#{File.basename(@file_name)}"), job.arguments.first[:full_file_name]
230
+ assert job.queued?
231
+ end
232
+ end
233
+ end
234
+
69
235
  end
70
236
  end