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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +38 -316
  3. data/bin/rocketjob_batch_perf +1 -1
  4. data/lib/rocket_job/batch/categories.rb +39 -26
  5. data/lib/rocket_job/batch/io.rb +53 -22
  6. data/lib/rocket_job/batch/logger.rb +2 -2
  7. data/lib/rocket_job/batch/model.rb +2 -2
  8. data/lib/rocket_job/batch/performance.rb +1 -1
  9. data/lib/rocket_job/batch/statistics.rb +9 -2
  10. data/lib/rocket_job/batch/throttle.rb +7 -2
  11. data/lib/rocket_job/batch/throttle_running_workers.rb +5 -1
  12. data/lib/rocket_job/batch/throttle_windows.rb +4 -2
  13. data/lib/rocket_job/batch/worker.rb +51 -11
  14. data/lib/rocket_job/category/base.rb +119 -1
  15. data/lib/rocket_job/category/input.rb +166 -40
  16. data/lib/rocket_job/category/output.rb +7 -0
  17. data/lib/rocket_job/config.rb +6 -1
  18. data/lib/rocket_job/deprecation.rb +51 -0
  19. data/lib/rocket_job/dirmon_entry.rb +6 -4
  20. data/lib/rocket_job/event.rb +84 -4
  21. data/lib/rocket_job/extensions/mongo/logging.rb +1 -0
  22. data/lib/rocket_job/extensions/mongoid/clients/options.rb +3 -3
  23. data/lib/rocket_job/extensions/mongoid/contextual/mongo.rb +3 -4
  24. data/lib/rocket_job/extensions/mongoid/factory.rb +2 -2
  25. data/lib/rocket_job/extensions/psych/yaml_tree.rb +8 -4
  26. data/lib/rocket_job/extensions/rocket_job_adapter.rb +1 -1
  27. data/lib/rocket_job/jobs/active_job.rb +1 -1
  28. data/lib/rocket_job/jobs/copy_file_job.rb +11 -1
  29. data/lib/rocket_job/jobs/dirmon_job.rb +3 -1
  30. data/lib/rocket_job/jobs/on_demand_batch_job.rb +1 -1
  31. data/lib/rocket_job/jobs/upload_file_job.rb +8 -5
  32. data/lib/rocket_job/lookup_collection.rb +2 -2
  33. data/lib/rocket_job/performance.rb +1 -1
  34. data/lib/rocket_job/plugins/cron.rb +6 -3
  35. data/lib/rocket_job/plugins/document.rb +0 -2
  36. data/lib/rocket_job/plugins/job/logger.rb +2 -2
  37. data/lib/rocket_job/plugins/job/model.rb +1 -1
  38. data/lib/rocket_job/plugins/job/persistence.rb +2 -2
  39. data/lib/rocket_job/plugins/job/throttle.rb +14 -2
  40. data/lib/rocket_job/plugins/job/throttle_running_jobs.rb +4 -1
  41. data/lib/rocket_job/plugins/job/worker.rb +5 -2
  42. data/lib/rocket_job/plugins/retry.rb +36 -35
  43. data/lib/rocket_job/plugins/throttle_dependent_jobs.rb +7 -2
  44. data/lib/rocket_job/plugins/transaction.rb +2 -2
  45. data/lib/rocket_job/sliced/input.rb +17 -12
  46. data/lib/rocket_job/sliced/slice.rb +3 -3
  47. data/lib/rocket_job/sliced/slices.rb +3 -6
  48. data/lib/rocket_job/sliced/writer/input.rb +4 -4
  49. data/lib/rocket_job/supervisor.rb +2 -2
  50. data/lib/rocket_job/thread_worker.rb +3 -3
  51. data/lib/rocket_job/throttle_definition.rb +19 -2
  52. data/lib/rocket_job/throttle_definitions.rb +14 -9
  53. data/lib/rocket_job/version.rb +1 -1
  54. data/lib/rocket_job/worker.rb +77 -28
  55. data/lib/rocketjob.rb +2 -2
  56. metadata +34 -27
  57. data/lib/rocket_job/ractor_worker.rb +0 -42
@@ -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').option(:encode, cleaner: :printable, replace: '')
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(:encode, encoding: 'UTF-8').stream(:zip).stream(:enc)
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).option(:encode, encoding: 'UTF-8')
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, slice_batch_size: nil, **args, &block)
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 > 0
278
- raise(ArgumentError, "Unknown keyword arguments when uploading a block. Only accepts :category, :file_name, or :on_first")
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 > 0
296
- raise(ArgumentError, "Unknown keyword arguments when uploading a Range. Only accepts :category, :columns, or :slice_batch_size")
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 > 0
308
- raise(ArgumentError, "Unknown keyword arguments when uploading a Mongoid::Criteria. Only accepts :category, :columns, or :slice_batch_size")
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 > 0
314
- raise(ArgumentError, "Unknown keyword arguments when uploading an ActiveRecord::Relation. Only accepts :category, :columns, or :slice_batch_size")
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
- # Extract the header line during the upload when applicable.
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
- path.each(stream_mode || :line, **args) { |line| io << line }
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, &block)
340
- count = input(category).upload_arel(arel, columns: column_names, &block)
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, &block)
347
- count = input(category).upload_mongo_query(criteria, columns: column_names, &block)
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, slice_batch_size: slice_batch_size)
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, zip_file_name: 'myfile.csv')
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&.is_a?(IOStreams::Stream) ? stream.dup : IOStreams.new(category.file_name)
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(&block)
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
- &block
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((((secs / percent) * 100) - secs))
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(time_zone))
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
@@ -81,7 +81,7 @@ module RocketJob
81
81
  o.on("-E", "--encrypt", "Turn on encryption") do
82
82
  self.encrypt = true
83
83
  end
84
- o.on("-s", "--slice_size COUNT", "Slice size") do
84
+ o.on("-s", "--slice_size COUNT", "Slice size") do |arg|
85
85
  self.slice_size = arg.to_i
86
86
  end
87
87
  end
@@ -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
- target.key?(sub_key) ? target[sub_key] : target[sub_key] = Hash.new(0)
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
- last_target[last] += increment
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?, filter: :throttle_filter_id
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?, filter: :throttle_filter_id
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.utc + 1
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(&block)
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, &block)
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
- filter = self.class.rocket_job_batch_throttles.matching_filter(self, slice)
115
- return false unless filter
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(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..-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
- SemanticLogger.named_tagged(record: slice.current_record_number) do
166
- writer << rocket_job_batch_perform(slice, record, &block)
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 = slice.current_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 = self.class.with(write: {w: 1}) do |query|
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::Tabular.registered_formats
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