rocketjob 6.3.2 → 7.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/README.md +38 -316
- data/bin/rocketjob_batch_perf +1 -1
- data/lib/rocket_job/batch/categories.rb +39 -26
- data/lib/rocket_job/batch/io.rb +53 -22
- data/lib/rocket_job/batch/logger.rb +2 -2
- data/lib/rocket_job/batch/model.rb +2 -2
- data/lib/rocket_job/batch/performance.rb +1 -1
- data/lib/rocket_job/batch/statistics.rb +9 -2
- data/lib/rocket_job/batch/throttle.rb +7 -2
- data/lib/rocket_job/batch/throttle_running_workers.rb +5 -1
- data/lib/rocket_job/batch/throttle_windows.rb +4 -2
- data/lib/rocket_job/batch/worker.rb +51 -11
- data/lib/rocket_job/category/base.rb +119 -1
- data/lib/rocket_job/category/input.rb +166 -40
- data/lib/rocket_job/category/output.rb +7 -0
- data/lib/rocket_job/config.rb +6 -1
- data/lib/rocket_job/deprecation.rb +51 -0
- data/lib/rocket_job/dirmon_entry.rb +6 -4
- data/lib/rocket_job/event.rb +84 -4
- data/lib/rocket_job/extensions/mongo/logging.rb +1 -0
- data/lib/rocket_job/extensions/mongoid/clients/options.rb +3 -3
- data/lib/rocket_job/extensions/mongoid/contextual/mongo.rb +3 -4
- data/lib/rocket_job/extensions/mongoid/factory.rb +2 -2
- data/lib/rocket_job/extensions/psych/yaml_tree.rb +8 -4
- data/lib/rocket_job/extensions/rocket_job_adapter.rb +1 -1
- data/lib/rocket_job/jobs/active_job.rb +1 -1
- data/lib/rocket_job/jobs/copy_file_job.rb +11 -1
- data/lib/rocket_job/jobs/dirmon_job.rb +3 -1
- data/lib/rocket_job/jobs/on_demand_batch_job.rb +1 -1
- data/lib/rocket_job/jobs/upload_file_job.rb +8 -5
- data/lib/rocket_job/lookup_collection.rb +2 -2
- data/lib/rocket_job/performance.rb +1 -1
- data/lib/rocket_job/plugins/cron.rb +6 -3
- data/lib/rocket_job/plugins/document.rb +0 -2
- data/lib/rocket_job/plugins/job/logger.rb +2 -2
- data/lib/rocket_job/plugins/job/model.rb +1 -1
- data/lib/rocket_job/plugins/job/persistence.rb +2 -2
- data/lib/rocket_job/plugins/job/throttle.rb +14 -2
- data/lib/rocket_job/plugins/job/throttle_running_jobs.rb +4 -1
- data/lib/rocket_job/plugins/job/worker.rb +5 -2
- data/lib/rocket_job/plugins/retry.rb +36 -35
- data/lib/rocket_job/plugins/throttle_dependent_jobs.rb +7 -2
- data/lib/rocket_job/plugins/transaction.rb +2 -2
- data/lib/rocket_job/sliced/input.rb +17 -12
- data/lib/rocket_job/sliced/slice.rb +3 -3
- data/lib/rocket_job/sliced/slices.rb +3 -6
- data/lib/rocket_job/sliced/writer/input.rb +4 -4
- data/lib/rocket_job/supervisor.rb +2 -2
- data/lib/rocket_job/thread_worker.rb +3 -3
- data/lib/rocket_job/throttle_definition.rb +19 -2
- data/lib/rocket_job/throttle_definitions.rb +14 -9
- data/lib/rocket_job/version.rb +1 -1
- data/lib/rocket_job/worker.rb +77 -28
- data/lib/rocketjob.rb +2 -2
- metadata +34 -27
- data/lib/rocket_job/ractor_worker.rb +0 -42
data/lib/rocket_job/batch/io.rb
CHANGED
|
@@ -95,6 +95,9 @@ module RocketJob
|
|
|
95
95
|
# Parses each line from the file as an Array and uploads each array for processing by workers.
|
|
96
96
|
# :hash
|
|
97
97
|
# Parses each line from the file into a Hash and uploads each hash for processing by workers.
|
|
98
|
+
# Default: :line
|
|
99
|
+
# With a schema, the default is the mode of the input category, and the lines of the file are uploaded
|
|
100
|
+
# in the :line and :hash modes, see RocketJob::Category::Input#mode.
|
|
98
101
|
# See IOStreams::Stream#each.
|
|
99
102
|
#
|
|
100
103
|
# category [Symbol|RocketJob::Category::Input]
|
|
@@ -109,7 +112,7 @@ module RocketJob
|
|
|
109
112
|
# Example:
|
|
110
113
|
# # Load plain text records from a file, stripping all non-printable characters,
|
|
111
114
|
# # as well as any characters that cannot be converted to UTF-8
|
|
112
|
-
# path = IOStreams.path('hello.csv').
|
|
115
|
+
# path = IOStreams.path('hello.csv').encoding(cleaner: :printable, replace: '')
|
|
113
116
|
# job.upload(path)
|
|
114
117
|
#
|
|
115
118
|
# Example: Zip
|
|
@@ -120,11 +123,11 @@ module RocketJob
|
|
|
120
123
|
# job.upload('myfile.csv.zip.enc')
|
|
121
124
|
#
|
|
122
125
|
# Example: Explicitly set the streams
|
|
123
|
-
# path = IOStreams.path('myfile.ze').stream(:
|
|
126
|
+
# path = IOStreams.path('myfile.ze').stream(:zip).stream(:enc).encoding('UTF-8')
|
|
124
127
|
# job.upload(path)
|
|
125
128
|
#
|
|
126
129
|
# Example: Supply custom options
|
|
127
|
-
# path = IOStreams.path('myfile.csv.enc').option(:enc, compress: false).
|
|
130
|
+
# path = IOStreams.path('myfile.csv.enc').option(:enc, compress: false).encoding('UTF-8')
|
|
128
131
|
# job.upload(path)
|
|
129
132
|
#
|
|
130
133
|
# Example: Read from a tempfile and use the original file name to determine which streams to apply
|
|
@@ -269,13 +272,15 @@ module RocketJob
|
|
|
269
272
|
# * If an exception is raised while uploading data, the input collection is cleared out
|
|
270
273
|
# so that if a job is retried during an upload failure, data is not duplicated.
|
|
271
274
|
|
|
272
|
-
def upload(object = nil, category: :main, file_name: nil, stream_mode: nil, on_first: nil, columns: nil,
|
|
275
|
+
def upload(object = nil, category: :main, file_name: nil, stream_mode: nil, on_first: nil, columns: nil,
|
|
276
|
+
slice_batch_size: nil, **args, &block)
|
|
273
277
|
input_collection = input(category)
|
|
274
278
|
|
|
275
279
|
if block
|
|
276
280
|
raise(ArgumentError, "Cannot supply both an object to upload, and a block.") if object
|
|
277
|
-
if stream_mode || columns || slice_batch_size || args.size
|
|
278
|
-
raise(ArgumentError,
|
|
281
|
+
if stream_mode || columns || slice_batch_size || args.size.positive?
|
|
282
|
+
raise(ArgumentError,
|
|
283
|
+
"Unknown keyword arguments when uploading a block. Only accepts :category, :file_name, or :on_first")
|
|
279
284
|
end
|
|
280
285
|
|
|
281
286
|
category = input_category(category)
|
|
@@ -292,8 +297,9 @@ module RocketJob
|
|
|
292
297
|
count =
|
|
293
298
|
case object
|
|
294
299
|
when Range
|
|
295
|
-
if file_name || stream_mode || on_first || args.size
|
|
296
|
-
raise(ArgumentError,
|
|
300
|
+
if file_name || stream_mode || on_first || args.size.positive?
|
|
301
|
+
raise(ArgumentError,
|
|
302
|
+
"Unknown keyword arguments when uploading a Range. Only accepts :category, :columns, or :slice_batch_size")
|
|
297
303
|
end
|
|
298
304
|
|
|
299
305
|
first = object.first
|
|
@@ -304,14 +310,16 @@ module RocketJob
|
|
|
304
310
|
input_collection.upload_integer_range_in_reverse_order(last, first, slice_batch_size: slice_batch_size || 1_000)
|
|
305
311
|
end
|
|
306
312
|
when Mongoid::Criteria
|
|
307
|
-
if file_name || stream_mode || on_first || args.size
|
|
308
|
-
raise(ArgumentError,
|
|
313
|
+
if file_name || stream_mode || on_first || args.size.positive?
|
|
314
|
+
raise(ArgumentError,
|
|
315
|
+
"Unknown keyword arguments when uploading a Mongoid::Criteria. Only accepts :category, :columns, or :slice_batch_size")
|
|
309
316
|
end
|
|
310
317
|
|
|
311
318
|
input_collection.upload_mongo_query(object, columns: columns, slice_batch_size: slice_batch_size, &block)
|
|
312
319
|
when defined?(ActiveRecord::Relation) ? ActiveRecord::Relation : false
|
|
313
|
-
if file_name || stream_mode || on_first || args.size
|
|
314
|
-
raise(ArgumentError,
|
|
320
|
+
if file_name || stream_mode || on_first || args.size.positive?
|
|
321
|
+
raise(ArgumentError,
|
|
322
|
+
"Unknown keyword arguments when uploading an ActiveRecord::Relation. Only accepts :category, :columns, or :slice_batch_size")
|
|
315
323
|
end
|
|
316
324
|
|
|
317
325
|
input_collection.upload_arel(object, columns: columns, slice_batch_size: slice_batch_size, &block)
|
|
@@ -319,14 +327,16 @@ module RocketJob
|
|
|
319
327
|
else
|
|
320
328
|
raise(ArgumentError, "Unknown keyword argument :columns when uploading a file") if columns
|
|
321
329
|
|
|
330
|
+
rocketjob_warn_deprecated_upload_options(args)
|
|
322
331
|
category = input_category(category)
|
|
323
332
|
|
|
324
|
-
#
|
|
333
|
+
# The path sets the format of a category whose format is :auto, which decides whether the file has a
|
|
334
|
+
# header row to extract during the upload.
|
|
335
|
+
path = category.upload_path(object, original_file_name: file_name)
|
|
325
336
|
extract_header = category.extract_header_callback(on_first)
|
|
326
|
-
path = category.upload_path(object, original_file_name: file_name)
|
|
327
337
|
|
|
328
338
|
input_collection.upload(on_first: extract_header, slice_batch_size: slice_batch_size) do |io|
|
|
329
|
-
|
|
339
|
+
category.each_record(path, mode: stream_mode, **args) { |record| io << record }
|
|
330
340
|
end
|
|
331
341
|
|
|
332
342
|
end
|
|
@@ -336,15 +346,15 @@ module RocketJob
|
|
|
336
346
|
end
|
|
337
347
|
|
|
338
348
|
# @deprecated
|
|
339
|
-
def upload_arel(arel, *column_names, category: :main, &
|
|
340
|
-
count = input(category).upload_arel(arel, columns: column_names, &
|
|
349
|
+
def upload_arel(arel, *column_names, category: :main, &)
|
|
350
|
+
count = input(category).upload_arel(arel, columns: column_names, &)
|
|
341
351
|
self.record_count = (record_count || 0) + count
|
|
342
352
|
count
|
|
343
353
|
end
|
|
344
354
|
|
|
345
355
|
# @deprecated
|
|
346
|
-
def upload_mongo_query(criteria, *column_names, category: :main, &
|
|
347
|
-
count = input(category).upload_mongo_query(criteria, columns: column_names, &
|
|
356
|
+
def upload_mongo_query(criteria, *column_names, category: :main, &)
|
|
357
|
+
count = input(category).upload_mongo_query(criteria, columns: column_names, &)
|
|
348
358
|
self.record_count = (record_count || 0) + count
|
|
349
359
|
count
|
|
350
360
|
end
|
|
@@ -358,7 +368,8 @@ module RocketJob
|
|
|
358
368
|
|
|
359
369
|
# @deprecated
|
|
360
370
|
def upload_integer_range_in_reverse_order(start_id, last_id, category: :main, slice_batch_size: 1_000)
|
|
361
|
-
count = input(category).upload_integer_range_in_reverse_order(start_id, last_id,
|
|
371
|
+
count = input(category).upload_integer_range_in_reverse_order(start_id, last_id,
|
|
372
|
+
slice_batch_size: slice_batch_size)
|
|
362
373
|
self.record_count = (record_count || 0) + count
|
|
363
374
|
count
|
|
364
375
|
end
|
|
@@ -386,6 +397,11 @@ module RocketJob
|
|
|
386
397
|
count
|
|
387
398
|
end
|
|
388
399
|
|
|
400
|
+
# The options that IOStreams reads a file with which describe its columns, and are deprecated, see
|
|
401
|
+
# RocketJob::Category::Input#schema.
|
|
402
|
+
DEPRECATED_UPLOAD_OPTIONS = %i[allowed_columns required_columns skip_unknown cleanse_header].freeze
|
|
403
|
+
private_constant :DEPRECATED_UPLOAD_OPTIONS
|
|
404
|
+
|
|
389
405
|
# Download the output data into the supplied file, io, IOStreams::Path, or IOStreams::Stream.
|
|
390
406
|
# Returns [Integer] the number of records / lines downloaded.
|
|
391
407
|
#
|
|
@@ -411,7 +427,7 @@ module RocketJob
|
|
|
411
427
|
# job.download(path)
|
|
412
428
|
#
|
|
413
429
|
# Example: Supply custom options. Set the file name within the zip file.
|
|
414
|
-
# path = IOStreams.path('myfile.csv.zip').option(:zip,
|
|
430
|
+
# path = IOStreams.path('myfile.csv.zip').option(:zip, entry_file_name: 'myfile.csv')
|
|
415
431
|
# job.download(path)
|
|
416
432
|
#
|
|
417
433
|
# Example: Download into a tempfile, or stream, using the original file name to determine the streams to apply:
|
|
@@ -459,7 +475,7 @@ module RocketJob
|
|
|
459
475
|
binary_header_line = output_collection.slice_class.to_binary(header_line) if header_line
|
|
460
476
|
|
|
461
477
|
# Don't overwrite supplied stream options if any
|
|
462
|
-
stream = stream
|
|
478
|
+
stream = stream.is_a?(IOStreams::Stream) ? stream.dup : IOStreams.new(category.file_name)
|
|
463
479
|
stream.remove_from_pipeline(output_collection.slice_class.binary_format)
|
|
464
480
|
stream.writer(**args) do |io|
|
|
465
481
|
# TODO: Binary formats should return the record count, instead of the slice count.
|
|
@@ -473,6 +489,21 @@ module RocketJob
|
|
|
473
489
|
end
|
|
474
490
|
end
|
|
475
491
|
end
|
|
492
|
+
|
|
493
|
+
private
|
|
494
|
+
|
|
495
|
+
# Reports a deprecation warning, see RocketJob::Deprecation, at the line of the application that uploads a file
|
|
496
|
+
# with options that describe the columns of the file, which the schema of the input category replaces.
|
|
497
|
+
def rocketjob_warn_deprecated_upload_options(args)
|
|
498
|
+
names = args.keys & DEPRECATED_UPLOAD_OPTIONS
|
|
499
|
+
return if names.empty?
|
|
500
|
+
|
|
501
|
+
RocketJob::Deprecation.warn(
|
|
502
|
+
"#{names.collect { |name| "#{name}:" }.join(', ')} #{names.size == 1 ? 'is' : 'are'} deprecated, describe " \
|
|
503
|
+
"the columns of the file with the schema: of the input category instead, see " \
|
|
504
|
+
"#{RocketJob::Deprecation::UPGRADE_GUIDE}"
|
|
505
|
+
)
|
|
506
|
+
end
|
|
476
507
|
end
|
|
477
508
|
end
|
|
478
509
|
end
|
|
@@ -29,7 +29,7 @@ module RocketJob
|
|
|
29
29
|
# - log_exception logs entire exception if raised
|
|
30
30
|
# - on_exception_level changes log level from info to error on exception
|
|
31
31
|
# - silence noisy jobs by raising log level
|
|
32
|
-
def rocket_job_batch_slice_logger(&
|
|
32
|
+
def rocket_job_batch_slice_logger(&)
|
|
33
33
|
logger.measure_info(
|
|
34
34
|
"Completed slice",
|
|
35
35
|
metric: "#{self.class.name}/slice",
|
|
@@ -37,7 +37,7 @@ module RocketJob
|
|
|
37
37
|
on_exception_level: :error,
|
|
38
38
|
silence: log_level,
|
|
39
39
|
payload: {records: rocket_job_slice&.size},
|
|
40
|
-
&
|
|
40
|
+
&
|
|
41
41
|
)
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -66,14 +66,14 @@ module RocketJob
|
|
|
66
66
|
percent = percent_complete
|
|
67
67
|
if percent >= 5
|
|
68
68
|
secs = seconds.to_f
|
|
69
|
-
h["est_remaining_duration"] = RocketJob.seconds_as_duration(((
|
|
69
|
+
h["est_remaining_duration"] = RocketJob.seconds_as_duration(((secs / percent) * 100) - secs)
|
|
70
70
|
end
|
|
71
71
|
end
|
|
72
72
|
elsif completed?
|
|
73
73
|
secs = seconds.to_f
|
|
74
74
|
h["records_per_hour"] = ((record_count.to_f / secs) * 60 * 60).round if record_count&.positive? && (secs > 0.0)
|
|
75
75
|
end
|
|
76
|
-
h.merge!(super
|
|
76
|
+
h.merge!(super)
|
|
77
77
|
h.delete("result")
|
|
78
78
|
# Worker name should be retrieved from the slices when processing
|
|
79
79
|
h.delete("worker_name") if sub_state == :processing
|
|
@@ -50,9 +50,16 @@ module RocketJob
|
|
|
50
50
|
return unless last
|
|
51
51
|
|
|
52
52
|
last_target = paths.inject(in_memory) do |target, sub_key|
|
|
53
|
-
|
|
53
|
+
# Assigning a Hash into a BSON::Document (Mongoid 9) stores a
|
|
54
|
+
# converted copy, so read the value back rather than reusing the
|
|
55
|
+
# assigned object, otherwise nested increments are lost.
|
|
56
|
+
target[sub_key] = Hash.new(0) unless target.key?(sub_key)
|
|
57
|
+
target[sub_key]
|
|
54
58
|
end
|
|
55
|
-
|
|
59
|
+
# Mongoid 9 demongoizes Hash fields to a BSON::Document without a
|
|
60
|
+
# default, so the leaf value may be nil even when the field default
|
|
61
|
+
# is `Hash.new(0)`. Default missing leaves to zero before adding.
|
|
62
|
+
last_target[last] = (last_target[last] || 0) + increment
|
|
56
63
|
end
|
|
57
64
|
end
|
|
58
65
|
|
|
@@ -45,11 +45,16 @@ module RocketJob
|
|
|
45
45
|
# Or, a block that will return the filter.
|
|
46
46
|
# Default: :throttle_filter_class (Throttle all jobs of this class)
|
|
47
47
|
#
|
|
48
|
+
# description: [String|Proc]
|
|
49
|
+
# Human readable reason why the job is throttled, persisted to `throttled_by`
|
|
50
|
+
# and shown in Mission Control. A Proc is called with the job and slice and
|
|
51
|
+
# must return a String. Default: a humanized version of the method name.
|
|
52
|
+
#
|
|
48
53
|
# Note: Throttles are executed in the order they are defined.
|
|
49
|
-
def define_batch_throttle(method_name, filter: :throttle_filter_class)
|
|
54
|
+
def define_batch_throttle(method_name, filter: :throttle_filter_class, description: nil)
|
|
50
55
|
# Duplicate to prevent modifying parent class throttles
|
|
51
56
|
definitions = rocket_job_batch_throttles ? rocket_job_batch_throttles.dup : ThrottleDefinitions.new
|
|
52
|
-
definitions.add(method_name, filter)
|
|
57
|
+
definitions.add(method_name, filter, description)
|
|
53
58
|
self.rocket_job_batch_throttles = definitions
|
|
54
59
|
end
|
|
55
60
|
|
|
@@ -36,7 +36,11 @@ module RocketJob
|
|
|
36
36
|
|
|
37
37
|
validates :throttle_running_workers, numericality: {greater_than_or_equal_to: 0}, allow_nil: true
|
|
38
38
|
|
|
39
|
-
define_batch_throttle :throttle_running_workers_exceeded?,
|
|
39
|
+
define_batch_throttle :throttle_running_workers_exceeded?,
|
|
40
|
+
filter: :throttle_filter_id,
|
|
41
|
+
description: lambda { |job, *|
|
|
42
|
+
"Throttled: maximum of #{job.throttle_running_workers} running workers reached"
|
|
43
|
+
}
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
private
|
|
@@ -43,7 +43,9 @@ module RocketJob
|
|
|
43
43
|
# Duration in seconds of the secondary window.
|
|
44
44
|
field :secondary_duration, type: Integer, class_attribute: true, user_editable: true, copy_on_restart: true
|
|
45
45
|
|
|
46
|
-
define_batch_throttle :throttle_windows_exceeded?,
|
|
46
|
+
define_batch_throttle :throttle_windows_exceeded?,
|
|
47
|
+
filter: :throttle_filter_id,
|
|
48
|
+
description: "Throttled: outside of its processing window"
|
|
47
49
|
|
|
48
50
|
validates_each :primary_schedule, :secondary_schedule do |record, attr, value|
|
|
49
51
|
record.errors.add(attr, "Invalid #{attr}: #{value.inspect}") if value && !Fugit::Cron.new(value)
|
|
@@ -62,7 +64,7 @@ module RocketJob
|
|
|
62
64
|
|
|
63
65
|
def throttle_outside_window?(schedule, duration)
|
|
64
66
|
cron = Fugit::Cron.new(schedule)
|
|
65
|
-
time = Time.now.
|
|
67
|
+
time = Time.now.getutc + 1
|
|
66
68
|
# Add 1 second since right now could be the very beginning of the processing window.
|
|
67
69
|
previous_time = cron.previous_time(time).to_utc_time
|
|
68
70
|
previous_time + duration < time
|
|
@@ -69,7 +69,7 @@ module RocketJob
|
|
|
69
69
|
# Note: The slice will be removed from processing when this method completes
|
|
70
70
|
#
|
|
71
71
|
# @deprecated Please open a ticket if you need this behavior.
|
|
72
|
-
def work_first_slice(&
|
|
72
|
+
def work_first_slice(&)
|
|
73
73
|
raise "#work_first_slice can only be called from within before_batch callbacks" unless sub_state == :before
|
|
74
74
|
|
|
75
75
|
# TODO: Make these settings configurable
|
|
@@ -89,7 +89,7 @@ module RocketJob
|
|
|
89
89
|
|
|
90
90
|
# TODO: Persist that the first slice is being processed by this worker
|
|
91
91
|
slice.start
|
|
92
|
-
rocket_job_process_slice(slice, &
|
|
92
|
+
rocket_job_process_slice(slice, &)
|
|
93
93
|
end
|
|
94
94
|
|
|
95
95
|
# Returns [Array<ActiveWorker>] All workers actively working on this job
|
|
@@ -111,15 +111,52 @@ module RocketJob
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def rocket_job_batch_throttled?(slice, worker)
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
throttle = self.class.rocket_job_batch_throttles.matching_throttle(self, slice)
|
|
115
|
+
unless throttle
|
|
116
|
+
rocket_job_clear_throttled
|
|
117
|
+
return false
|
|
118
|
+
end
|
|
116
119
|
|
|
117
120
|
# Restore retrieved slice so that other workers can process it later.
|
|
118
121
|
slice.set(worker_name: nil, state: :queued, started_at: nil)
|
|
119
|
-
worker.add_to_current_filter(
|
|
122
|
+
worker.add_to_current_filter(throttle.extract_filter(self, slice))
|
|
123
|
+
rocket_job_set_throttled(throttle.extract_description(self, slice))
|
|
120
124
|
true
|
|
121
125
|
end
|
|
122
126
|
|
|
127
|
+
# Record why this batch job's slices are being throttled, for visibility in
|
|
128
|
+
# Mission Control.
|
|
129
|
+
#
|
|
130
|
+
# Unlike a simple job, a running batch job document is shared by every worker
|
|
131
|
+
# and is deliberately not written on each poll (see Worker#find_and_assign_job).
|
|
132
|
+
# The reason is therefore persisted with a guarded conditional update so the
|
|
133
|
+
# shared document is only written when the reason actually changes, not on
|
|
134
|
+
# every throttled poll.
|
|
135
|
+
def rocket_job_set_throttled(reason)
|
|
136
|
+
return if throttled_by == reason
|
|
137
|
+
|
|
138
|
+
self.class.with(write: {w: 1}) do |query|
|
|
139
|
+
query.where(id: id, state: :running, :throttled_by.ne => reason).
|
|
140
|
+
update_all("$set" => {throttled_by: reason, throttled_at: Time.now})
|
|
141
|
+
end
|
|
142
|
+
self.throttled_by = reason
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Clear the throttle reason once slices are flowing again.
|
|
146
|
+
#
|
|
147
|
+
# Gated on the in-memory value so a worker that never throttled this job
|
|
148
|
+
# issues no database round trip per slice; only a worker that previously set
|
|
149
|
+
# a reason clears it, with a guarded update to avoid a redundant write.
|
|
150
|
+
def rocket_job_clear_throttled
|
|
151
|
+
return if throttled_by.nil?
|
|
152
|
+
|
|
153
|
+
self.class.with(write: {w: 1}) do |query|
|
|
154
|
+
query.where(id: id, :throttled_by.ne => nil).
|
|
155
|
+
update_all("$unset" => {throttled_by: "", throttled_at: ""})
|
|
156
|
+
end
|
|
157
|
+
self.throttled_by = nil
|
|
158
|
+
end
|
|
159
|
+
|
|
123
160
|
# Process a single slice from Mongo
|
|
124
161
|
# Once the slice has been successfully processed it will be removed from the input collection
|
|
125
162
|
# Returns [Integer] the number of records successfully processed
|
|
@@ -151,7 +188,7 @@ module RocketJob
|
|
|
151
188
|
if slice.processing_record_number.to_i > 1
|
|
152
189
|
append = true
|
|
153
190
|
logger.info("Resuming previously incomplete slice from record number #{slice.processing_record_number}")
|
|
154
|
-
slice.records[slice.processing_record_number - 1
|
|
191
|
+
slice.records[(slice.processing_record_number - 1)..]
|
|
155
192
|
else
|
|
156
193
|
# Reprocess all records in this slice.
|
|
157
194
|
slice.processing_record_number = 0
|
|
@@ -162,8 +199,11 @@ module RocketJob
|
|
|
162
199
|
RocketJob::Sliced::Writer::Output.collect(self, input_slice: slice, append: append) do |writer|
|
|
163
200
|
records.each do |record|
|
|
164
201
|
slice.processing_record_number += 1
|
|
165
|
-
|
|
166
|
-
|
|
202
|
+
# `current_record_number` reads Mongoid fields, so compute it once per
|
|
203
|
+
# record and reuse it for both the log tag and `#perform`.
|
|
204
|
+
record_number = slice.current_record_number
|
|
205
|
+
SemanticLogger.named_tagged(record: record_number) do
|
|
206
|
+
writer << rocket_job_batch_perform(slice, record, record_number, &block)
|
|
167
207
|
count += 1
|
|
168
208
|
end
|
|
169
209
|
end
|
|
@@ -172,8 +212,8 @@ module RocketJob
|
|
|
172
212
|
end
|
|
173
213
|
|
|
174
214
|
# Perform a single record within the current slice.
|
|
175
|
-
def rocket_job_batch_perform(slice, record)
|
|
176
|
-
@rocket_job_record_number =
|
|
215
|
+
def rocket_job_batch_perform(slice, record, record_number = slice.current_record_number)
|
|
216
|
+
@rocket_job_record_number = record_number
|
|
177
217
|
|
|
178
218
|
return block_given? ? yield(record) : perform(record) if _perform_callbacks.empty?
|
|
179
219
|
|
|
@@ -248,7 +288,7 @@ module RocketJob
|
|
|
248
288
|
unless new_record?
|
|
249
289
|
# Fail job iff no other worker has already finished it
|
|
250
290
|
# Must set write concern to at least 1 since we need the nModified back
|
|
251
|
-
result
|
|
291
|
+
result = self.class.with(write: {w: 1}) do |query|
|
|
252
292
|
query.
|
|
253
293
|
where(id: id, state: :running, sub_state: :processing).
|
|
254
294
|
update({"$set" => {state: "failed", worker_name: worker_name}})
|
|
@@ -15,6 +15,10 @@ module RocketJob
|
|
|
15
15
|
# The header columns when the file does not include a header row.
|
|
16
16
|
# Note:
|
|
17
17
|
# - All column names must be strings so that it can be serialized into MongoDB.
|
|
18
|
+
# - With a schema, see #schema, the columns of an input category hold the header row read from the uploaded
|
|
19
|
+
# file, see RocketJob::Category::Input, and those of an output category cannot be set.
|
|
20
|
+
# - Deprecated without a schema, use `schema`, whose fields are the columns, with `"header": false` for a file
|
|
21
|
+
# without a header row.
|
|
18
22
|
field :columns, type: Array
|
|
19
23
|
|
|
20
24
|
# On an input collection `format` specifies the format of the input data so that it can be
|
|
@@ -28,17 +32,76 @@ module RocketJob
|
|
|
28
32
|
#
|
|
29
33
|
# Default: `nil`
|
|
30
34
|
field :format, type: ::Mongoid::StringifiedSymbol
|
|
31
|
-
validates_inclusion_of :format, in: [nil, :auto] + IOStreams
|
|
35
|
+
validates_inclusion_of :format, in: [nil, :auto] + IOStreams.tabular_formats
|
|
32
36
|
|
|
33
37
|
# Any specialized format specific options. For example, `:fixed` format requires a `:layout`.
|
|
38
|
+
#
|
|
39
|
+
# Deprecated, use `schema`, see #schema, which gives the width of each field of a fixed width file. It cannot be
|
|
40
|
+
# set with a schema.
|
|
34
41
|
field :format_options, type: Hash
|
|
35
42
|
|
|
43
|
+
# The fields of the records in this category and the type of each one, as a Frictionless Table Schema, see
|
|
44
|
+
# https://datapackage.org/standard/table-schema/, so that records are read as typed values, such as an Integer,
|
|
45
|
+
# a BigDecimal or a Date, and written in the text form of each type, for every tabular format. It also gives
|
|
46
|
+
# the position of each field of a fixed width file, with its `width`, and `"header": false` describes a file
|
|
47
|
+
# without a header row. See `IOStreams::Schema`, and `IOStreams::Stream#schema`.
|
|
48
|
+
#
|
|
49
|
+
# A Hash, or the path or url of a JSON file that holds the schema, which is read when the category is first
|
|
50
|
+
# used to read or write records.
|
|
51
|
+
#
|
|
52
|
+
# Example, for a fixed width file:
|
|
53
|
+
# {"fields" => [{"name" => "name", "type" => "string", "width" => 10},
|
|
54
|
+
# {"name" => "zip", "type" => "integer", "width" => 5}]}
|
|
55
|
+
#
|
|
56
|
+
# The schema describes the columns of the file, so `format_options` and `columns` cannot be set with it, nor on
|
|
57
|
+
# an input category `allowed_columns`, `required_columns`, `skip_unknown` and `header_cleanser`. It needs a
|
|
58
|
+
# `format`, such as `:csv`, or `:auto` to take it from the file name.
|
|
59
|
+
#
|
|
60
|
+
# Default: nil, which reads and writes each value as text, as before.
|
|
61
|
+
field :schema
|
|
62
|
+
validate :schema_valid
|
|
63
|
+
validate :schema_replaces_fields
|
|
64
|
+
|
|
65
|
+
# Setting the schema builds the tabular again from it when it is next used, see #tabular.
|
|
66
|
+
define_method(:schema=) do |value|
|
|
67
|
+
@tabular = nil
|
|
68
|
+
super(value)
|
|
69
|
+
end
|
|
70
|
+
|
|
36
71
|
# When `:format` is not supplied the file name can be used to infer the required format.
|
|
37
72
|
# Optional.
|
|
38
73
|
# Default: nil
|
|
39
74
|
field :file_name, type: IOStreams::Path
|
|
40
75
|
end
|
|
41
76
|
|
|
77
|
+
module ClassMethods
|
|
78
|
+
# Returns [Array<String>] the names of the deprecated options in the supplied properties of a category, with
|
|
79
|
+
# String keys, that its schema replaces, see #schema: its `columns`. Overridden by the input category.
|
|
80
|
+
def deprecated_column_options(properties)
|
|
81
|
+
properties["columns"].present? ? ["columns"] : []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Reports a deprecation warning, see RocketJob::Deprecation, for the deprecated options in the supplied
|
|
85
|
+
# properties of a category, such as those that a job class defines it with, or that a Dirmon entry holds.
|
|
86
|
+
def warn_deprecated_options(properties)
|
|
87
|
+
properties = properties.to_h.stringify_keys
|
|
88
|
+
names = deprecated_column_options(properties)
|
|
89
|
+
unless names.empty?
|
|
90
|
+
options = names.collect { |name| "#{name}:" }.join(", ")
|
|
91
|
+
RocketJob::Deprecation.warn(
|
|
92
|
+
"#{options} #{names.size == 1 ? 'is' : 'are'} deprecated, describe the columns of the file with schema: " \
|
|
93
|
+
"instead, see #{RocketJob::Deprecation::UPGRADE_GUIDE}"
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
return if properties["format_options"].blank?
|
|
97
|
+
|
|
98
|
+
RocketJob::Deprecation.warn(
|
|
99
|
+
"format_options: is deprecated, use schema: instead, which gives the width of each field of a fixed width " \
|
|
100
|
+
"file, see #{RocketJob::Deprecation::UPGRADE_GUIDE}"
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
42
105
|
# Return which slice serializer class to use that matches the current options.
|
|
43
106
|
def serializer_class
|
|
44
107
|
case serializer
|
|
@@ -58,6 +121,8 @@ module RocketJob
|
|
|
58
121
|
end
|
|
59
122
|
|
|
60
123
|
def tabular
|
|
124
|
+
return @tabular ||= schema_tabular if schema.present?
|
|
125
|
+
|
|
61
126
|
@tabular ||= IOStreams::Tabular.new(
|
|
62
127
|
columns: columns,
|
|
63
128
|
format: format == :auto ? nil : format,
|
|
@@ -76,6 +141,59 @@ module RocketJob
|
|
|
76
141
|
collection_name << ".#{name}" unless name == :main
|
|
77
142
|
collection_name
|
|
78
143
|
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
# Returns [IOStreams::Tabular] that reads or writes the records of this category by its schema, see #schema,
|
|
148
|
+
# reading the file that holds it when it is a path or url.
|
|
149
|
+
def schema_tabular
|
|
150
|
+
IOStreams::Tabular.new(
|
|
151
|
+
schema: IOStreams::Schema::Source.new(schema).schema,
|
|
152
|
+
format: format == :auto ? nil : format,
|
|
153
|
+
file_name: file_name
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns [Array<Symbol>] the fields of this category that are set, which its schema replaces, see #schema.
|
|
158
|
+
def fields_replaced_by_schema
|
|
159
|
+
format_options.present? ? [:format_options] : []
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# The fields that the schema replaces cannot be set with it, since IOStreams raises for them with a schema, rather
|
|
163
|
+
# than apply one and ignore the other.
|
|
164
|
+
def schema_replaces_fields
|
|
165
|
+
return if schema.blank?
|
|
166
|
+
|
|
167
|
+
errors.add(:schema, "needs a format, such as :csv, or :auto to take it from the file name") if format.blank?
|
|
168
|
+
replaced = fields_replaced_by_schema
|
|
169
|
+
errors.add(:schema, "replaces #{replaced.to_sentence}, which cannot be set with it") unless replaced.empty?
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Checks the schema without reading a file that holds it, since its store may not be available yet.
|
|
173
|
+
def schema_valid
|
|
174
|
+
case schema
|
|
175
|
+
when nil
|
|
176
|
+
nil
|
|
177
|
+
when Hash
|
|
178
|
+
IOStreams::Schema.new(schema)
|
|
179
|
+
when String
|
|
180
|
+
schema_path_valid
|
|
181
|
+
else
|
|
182
|
+
errors.add(:schema, "must be a Hash, or the path or url of a JSON file")
|
|
183
|
+
end
|
|
184
|
+
rescue ArgumentError => e
|
|
185
|
+
errors.add(:schema, "is not valid: #{e.message}")
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def schema_path_valid
|
|
189
|
+
IOStreams.path(schema)
|
|
190
|
+
rescue LoadError
|
|
191
|
+
# A gem that the path needs is not installed in this process, so the path is checked when the job runs.
|
|
192
|
+
nil
|
|
193
|
+
rescue StandardError
|
|
194
|
+
# The path is not included in the message, since a url can include credentials.
|
|
195
|
+
errors.add(:schema, "is not a valid path or url")
|
|
196
|
+
end
|
|
79
197
|
end
|
|
80
198
|
end
|
|
81
199
|
end
|