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.
- checksums.yaml +4 -4
- data/Rakefile +6 -7
- data/lib/rocket_job/cli.rb +14 -15
- data/lib/rocket_job/concerns/singleton.rb +33 -0
- data/lib/rocket_job/concerns/worker.rb +70 -20
- data/lib/rocket_job/config.rb +3 -1
- data/lib/rocket_job/dirmon_entry.rb +260 -30
- data/lib/rocket_job/heartbeat.rb +3 -0
- data/lib/rocket_job/job.rb +77 -154
- data/lib/rocket_job/job_exception.rb +8 -6
- data/lib/rocket_job/jobs/dirmon_job.rb +26 -102
- data/lib/rocket_job/version.rb +1 -1
- data/lib/rocket_job/worker.rb +40 -31
- data/lib/rocketjob.rb +26 -9
- data/test/dirmon_entry_test.rb +197 -31
- data/test/dirmon_job_test.rb +91 -188
- data/test/job_test.rb +148 -30
- data/test/job_worker_test.rb +23 -22
- data/test/test_helper.rb +9 -9
- data/test/worker_test.rb +8 -4
- metadata +6 -5
data/lib/rocket_job/version.rb
CHANGED
data/lib/rocket_job/worker.rb
CHANGED
@@ -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
|
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
|
116
|
-
#
|
117
|
-
|
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
|
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: [
|
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
|
-
|
139
|
+
running.each(&:pause!)
|
135
140
|
end
|
136
141
|
|
137
142
|
# Resume all paused workers
|
138
143
|
def self.resume_all
|
139
|
-
each
|
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 = '
|
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
|
-
|
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
|
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
|
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
|
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
|
333
|
+
logger.warn 'Shutdown signal (INT) received. Will shutdown as soon as active jobs/slices have completed.'
|
324
334
|
end
|
325
|
-
rescue
|
326
|
-
logger.warn
|
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,
|
10
|
-
autoload :Config,
|
11
|
-
autoload :DirmonEntry,
|
12
|
-
autoload :Heartbeat,
|
13
|
-
autoload :Job,
|
14
|
-
autoload :JobException,
|
15
|
-
autoload :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,
|
18
|
+
autoload :Worker, 'rocket_job/concerns/worker'
|
19
|
+
autoload :Singleton, 'rocket_job/concerns/singleton'
|
18
20
|
end
|
19
21
|
module Jobs
|
20
|
-
autoload :DirmonJob,
|
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
|
data/test/dirmon_entry_test.rb
CHANGED
@@ -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(
|
95
|
+
assert entry = RocketJob::DirmonEntry.new(job_class_name: 'Jobs::TestJob')
|
20
96
|
assert_equal false, entry.valid?
|
21
|
-
assert_equal [
|
97
|
+
assert_equal ["can't be blank"], entry.errors[:pattern], entry.errors.inspect
|
22
98
|
end
|
23
99
|
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
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
|
-
|
42
|
-
|
43
|
-
|
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 [
|
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
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|