rocketjob 7.0.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7cf0e18a83d97aadac0d2741a399bcb55fa33884ccf2660836a5a3cfd946f772
4
- data.tar.gz: d95563d0c77495dcdb3498dccf7cfc22fefee78bd22df244b823fc3939ef5373
3
+ metadata.gz: 4a97fb3e6b6ed4130f52e5796bdb0737661c20e83f158c1f00667629438ea316
4
+ data.tar.gz: 298727e29cd1f8d37cd4cab34c23d7a8b3fee805f0f264c784e72af873309640
5
5
  SHA512:
6
- metadata.gz: 8f379b7890ddce715719616c311b501a916bc081bfd8eaba5055fa124badd58084985d9f38da0c081a8b5bcff3c67108ba3ea5ca52d8f2a8d7a92aa7297c94e8
7
- data.tar.gz: db4e047d9c07faed020db569f7268fc0dea981ede5792ec40b7636718b118514fc6edc8d08b961d3dfe1d4ecadada40f548dde490ed4ede5326bbcb12ef5bedd
6
+ metadata.gz: df159598ced406d32f34802c65361c6f429075862722afb078324e265f4c71e779f12646292b1fe03d3a9e53bcd94c32f9f9e18aea3853260e21368b0e46bb5a
7
+ data.tar.gz: f1c0e817b687fa470f66ee3cbe7b519d52835813ad9ec99c96f092d557e1438f09358af1391b6288832df47c45249011d7d151668e782cf0207b25a431a274df
data/README.md CHANGED
@@ -1,5 +1,5 @@
1
1
  # Rocket Job
2
- [![Gem Version](https://img.shields.io/gem/v/rocketjob.svg)](https://rubygems.org/gems/rocketjob) [![Downloads](https://img.shields.io/gem/dt/rocketjob.svg)](https://rubygems.org/gems/rocketjob) [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](http://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/badge/status-Production%20Ready-blue.svg)
2
+ [![Gem Version](https://img.shields.io/gem/v/rocketjob.svg)](https://rubygems.org/gems/rocketjob) [![Downloads](https://img.shields.io/gem/dt/rocketjob.svg)](https://rubygems.org/gems/rocketjob) [![Coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/reidmorrison/rocketjob/badges/coverage.json)](https://github.com/reidmorrison/rocketjob/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache%202.0-brightgreen.svg)](http://opensource.org/licenses/Apache-2.0) ![](https://img.shields.io/badge/status-Production%20Ready-blue.svg)
3
3
 
4
4
  **Process millions of records across thousands of workers.**
5
5
 
@@ -29,6 +29,7 @@ module RocketJob
29
29
  # Define a new input category
30
30
  # @see RocketJob::Category::Input
31
31
  def input_category(**args)
32
+ RocketJob::Category::Input.warn_deprecated_options(args)
32
33
  category = RocketJob::Category::Input.new(**args)
33
34
  if defined_input_categories.nil?
34
35
  self.defined_input_categories = [category]
@@ -40,6 +41,7 @@ module RocketJob
40
41
  # Define a new output category
41
42
  # @see RocketJob::Category::Output
42
43
  def output_category(**args)
44
+ RocketJob::Category::Output.warn_deprecated_options(args)
43
45
  category = RocketJob::Category::Output.new(**args)
44
46
  if defined_output_categories.nil?
45
47
  self.defined_output_categories = [category]
@@ -120,28 +122,39 @@ module RocketJob
120
122
  false
121
123
  end
122
124
 
125
+ # Sets the supplied properties of each input category on it, such as those that a Dirmon entry or an
126
+ # UploadFileJob holds, and reports a deprecation warning for a deprecated option among them, see
127
+ # Category::Base.warn_deprecated_options.
123
128
  def merge_input_categories(categories)
124
129
  return if categories.blank?
125
130
 
126
131
  categories.each do |properties|
127
132
  category_name = (properties["name"] || properties[:name] || :main).to_sym
128
133
  category = input_category(category_name)
129
- properties.each { |key, value| category.public_send(:"#{key}=", value) }
134
+ rocketjob_categories_merge(category, properties)
130
135
  end
131
136
  end
132
137
 
138
+ # Sets the supplied properties of each output category on it, see #merge_input_categories.
133
139
  def merge_output_categories(categories)
134
140
  return if categories.blank?
135
141
 
136
142
  categories.each do |properties|
137
143
  category_name = (properties["name"] || properties[:name] || :main).to_sym
138
144
  category = output_category(category_name)
139
- properties.each { |key, value| category.public_send(:"#{key}=", value) }
145
+ rocketjob_categories_merge(category, properties)
140
146
  end
141
147
  end
142
148
 
143
149
  private
144
150
 
151
+ # Sets the supplied properties on the category, after reporting a deprecation warning for a deprecated option
152
+ # among them, which includes the category's schema, since an input category's columns are not deprecated with one.
153
+ def rocketjob_categories_merge(category, properties)
154
+ category.class.warn_deprecated_options(category.attributes.slice("schema").merge(properties.to_h.stringify_keys))
155
+ properties.each { |key, value| category.public_send(:"#{key}=", value) }
156
+ end
157
+
145
158
  def rocketjob_categories_assign
146
159
  # Input categories defaults to :main if none was set in the class
147
160
  if input_categories.empty?
@@ -218,7 +231,7 @@ module RocketJob
218
231
 
219
232
  # Migrate existing v5 batch jobs to v6
220
233
  def rocketjob_categories_migrate
221
- return unless attribute_present?(:input_categories) && self[:input_categories]&.first.is_a?(Symbol)
234
+ return unless attributes["input_categories"]&.first.is_a?(Symbol)
222
235
 
223
236
  serializer = :none
224
237
  if attribute_present?(:compress)
@@ -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
@@ -324,14 +327,16 @@ module RocketJob
324
327
  else
325
328
  raise(ArgumentError, "Unknown keyword argument :columns when uploading a file") if columns
326
329
 
330
+ rocketjob_warn_deprecated_upload_options(args)
327
331
  category = input_category(category)
328
332
 
329
- # 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)
330
336
  extract_header = category.extract_header_callback(on_first)
331
- path = category.upload_path(object, original_file_name: file_name)
332
337
 
333
338
  input_collection.upload(on_first: extract_header, slice_batch_size: slice_batch_size) do |io|
334
- path.each(stream_mode || :line, **args) { |line| io << line }
339
+ category.each_record(path, mode: stream_mode, **args) { |record| io << record }
335
340
  end
336
341
 
337
342
  end
@@ -392,6 +397,11 @@ module RocketJob
392
397
  count
393
398
  end
394
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
+
395
405
  # Download the output data into the supplied file, io, IOStreams::Path, or IOStreams::Stream.
396
406
  # Returns [Integer] the number of records / lines downloaded.
397
407
  #
@@ -417,7 +427,7 @@ module RocketJob
417
427
  # job.download(path)
418
428
  #
419
429
  # Example: Supply custom options. Set the file name within the zip file.
420
- # 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')
421
431
  # job.download(path)
422
432
  #
423
433
  # Example: Download into a tempfile, or stream, using the original file name to determine the streams to apply:
@@ -479,6 +489,21 @@ module RocketJob
479
489
  end
480
490
  end
481
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
482
507
  end
483
508
  end
484
509
  end
@@ -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
@@ -21,9 +21,13 @@ module RocketJob
21
21
  # Note:
22
22
  # When supplied any columns that are rejected will be returned in the cleansed columns
23
23
  # as nil so that they can be ignored during processing.
24
+ #
25
+ # Deprecated, use `schema`, whose `fieldsMatch` says which columns a file can have.
24
26
  field :allowed_columns, type: Array
25
27
 
26
28
  # List of columns that must be present, otherwise an Exception is raised.
29
+ #
30
+ # Deprecated, use `schema`, with `constraints: {required: true}` on each field that a file must have.
27
31
  field :required_columns, type: Array
28
32
 
29
33
  # Whether to skip unknown columns in the uploaded file.
@@ -41,29 +45,26 @@ module RocketJob
41
45
  # Notes:
42
46
  # - Only applicable when `allowed_columns` has been set.
43
47
  # - Recommended to leave as `false` otherwise a misspelled column can result in missed columns.
48
+ # - Deprecated, use `schema`, whose `fieldsMatch` of "subset" or "partial" skips the other columns.
44
49
  field :skip_unknown, type: ::Mongoid::Boolean, default: false
45
50
  validates_inclusion_of :skip_unknown, in: [true, false]
46
51
 
47
- # When `#upload` is called with a file_name, it uploads the file using any of the following approaches:
52
+ # How `#upload` reads a file into a category with a schema, see #schema, unless the upload is given a
53
+ # `stream_mode:`:
48
54
  # :line
49
- # Uploads the file a line (String) at a time for processing by workers.
50
- # This is the default behavior and is the most performant since it leaves the parsing of each line
51
- # up to the workers themselves.
52
- # :array
53
- # Parses each line from the file as an Array and uploads each array for processing by workers.
54
- # Every line in the input file is parsed and converted into an array before uploading.
55
- # This approach ensures that the entire files is valid before starting to process it.
56
- # Ideal for when files may contain invalid lines.
57
- # Not recommended for large files since the CSV or other parsing is performed sequentially during the
58
- # upload process.
55
+ # Uploads the lines of the file, which the workers parse by the schema, so that each value reaches `#perform` as
56
+ # its type, such as a Date. This is the default, and the most performant, since the workers parse the lines.
59
57
  # :hash
60
- # Parses each line from the file into a Hash and uploads each hash for processing by workers.
61
- # Similar to :array above in that the entire file is parsed before processing is started.
62
- # Slightly less efficient than :array since it stores every record as a hash with both the key and value.
58
+ # Also parses every line by the schema when it is uploaded, so that a file with a value that is not valid is not
59
+ # uploaded at all. The lines are uploaded, as in :line mode. Not recommended for large files, since the lines
60
+ # are parsed one after the other during the upload.
61
+ # :array
62
+ # Cannot be used with a schema.
63
63
  #
64
- # Recommend using :array when the entire file must be parsed/validated before processing is started, and
65
- # upload time is not important.
66
- # See IOStreams#each for more details.
64
+ # Without a schema the mode does not change how a file is uploaded: its lines are uploaded, unless the upload is
65
+ # given `stream_mode: :array` or `stream_mode: :hash`, which parses each line into an Array or a Hash first, see
66
+ # RocketJob::Batch::IO#upload. A category with a format whose files have a header row, such as CSV, cannot be
67
+ # uploaded with `stream_mode: :hash` without a schema.
67
68
  field :mode, type: ::Mongoid::StringifiedSymbol, default: :line
68
69
  validates_inclusion_of :mode, in: %i[line array hash]
69
70
 
@@ -80,11 +81,27 @@ module RocketJob
80
81
  # Do not cleanse the columns names supplied in the header row.
81
82
  #
82
83
  # Note: Submit a ticket if you have other cleansers that you want added.
84
+ #
85
+ # Deprecated, use `schema`, whose fields name the keys of each record, and which the columns of the header row
86
+ # are matched to whatever their case, spaces and punctuation.
83
87
  field :header_cleanser, type: ::Mongoid::StringifiedSymbol, default: :default
84
88
  validates :header_cleanser, inclusion: %i[default none]
85
89
 
86
90
  validates_inclusion_of :serializer, in: %i[none compress encrypt]
87
91
 
92
+ # Returns [Array<String>] the names of the deprecated options in the supplied properties of an input category,
93
+ # with String keys, see Category::Base.deprecated_column_options: `allowed_columns`, `required_columns`,
94
+ # `skip_unknown` when true, `header_cleanser` when :none, and `columns` without a schema, since with one they
95
+ # hold the header row read from the file.
96
+ def self.deprecated_column_options(properties)
97
+ names = []
98
+ names << "columns" if properties["columns"].present? && properties["schema"].blank?
99
+ names.concat(%w[allowed_columns required_columns].select { |name| properties[name].present? })
100
+ names << "skip_unknown" if [true, "true"].include?(properties["skip_unknown"])
101
+ names << "header_cleanser" if properties["header_cleanser"].to_s == "none"
102
+ names
103
+ end
104
+
88
105
  # Cleanses the header column names when `cleanse_header` is true
89
106
  def cleanse_header!
90
107
  return unless header_cleanser == :default
@@ -96,6 +113,8 @@ module RocketJob
96
113
  end
97
114
 
98
115
  def tabular
116
+ return @tabular ||= schema_tabular if schema.present?
117
+
99
118
  @tabular ||= IOStreams::Tabular.new(
100
119
  columns: columns,
101
120
  format: format == :auto ? nil : format,
@@ -133,36 +152,143 @@ module RocketJob
133
152
  @tabular = nil
134
153
  end
135
154
 
136
- # Remove non-printable characters from tabular input formats.
137
- if tabular?
155
+ if schema.present?
156
+ # IOStreams reads the text of a file with a schema as UTF-8, and raises for text that is not valid UTF-8,
157
+ # unless an encoding is set on the supplied path, which reads it instead. A schema set on the supplied path
158
+ # is kept.
159
+ path.schema(schema) if path.schema.nil?
160
+ elsif tabular?
161
+ # Remove non-printable characters from tabular input formats.
138
162
  # Cannot change the length of fixed width lines.
139
163
  replace = format == :fixed ? " " : ""
140
- path.option_or_stream(:encode, encoding: "UTF-8", cleaner: :printable, replace: replace)
164
+ path.encoding("UTF-8", cleaner: :printable, replace: replace)
141
165
  end
142
166
  path
143
167
  end
144
168
 
145
- # Return a lambda to extract the header row from the uploaded file.
169
+ # Yields each record read from the supplied path for an upload, see RocketJob::Batch::IO#upload, in the supplied
170
+ # mode, otherwise in :line mode, see `IOStreams::Stream#each`.
171
+ #
172
+ # With a schema it yields the lines of the file in the supplied mode, otherwise in this category's mode, see
173
+ # #mode, and in :hash mode parses each line after the header row by the schema first, so that a value that is not
174
+ # valid raises before any record is processed.
175
+ def each_record(path, mode: nil, **args, &)
176
+ return path.each(mode || :line, **args, &) if schema.blank?
177
+
178
+ mode ||= self.mode
179
+ if mode == :array
180
+ raise(ArgumentError,
181
+ "Input category #{name} has a schema, which cannot be uploaded in :array mode, use :line or :hash")
182
+ end
183
+ return path.each(:line, **args, &) if mode == :line
184
+
185
+ path.each(:line, **args) do |line|
186
+ # The lines before the header row, and the header row itself, are read by #extract_header_callback.
187
+ tabular.record_parse(line) unless tabular.header?
188
+ yield(line)
189
+ end
190
+ end
191
+
192
+ # Returns the lambda that the upload calls with each record before the first that it stores, instead of storing
193
+ # it, until the lambda returns true, see `Sliced::Writer::Input`: it reads the header row, when the file has one,
194
+ # then calls the supplied `on_first` with it. Otherwise it calls `on_first` with the first record. Returns nil
195
+ # when there is neither.
196
+ #
197
+ # A blank line before the header row is skipped, as IOStreams skips it when it reads the header row itself, such
198
+ # as in :hash mode, so that the header row is not stored as a record.
199
+ #
200
+ # The header row is a line, or an Array when the upload parses each line with `stream_mode: :array`, which the
201
+ # format returns as it is.
146
202
  def extract_header_callback(on_first)
147
- return on_first unless tabular? && tabular.header?
148
-
149
- case mode
150
- when :line
151
- lambda do |line|
152
- tabular.parse_header(line)
153
- cleanse_header!
154
- self.columns = tabular.header.columns
155
- # Call chained on_first if present
156
- on_first&.call(line)
157
- end
158
- when :array
159
- lambda do |row|
160
- tabular.header.columns = row
161
- cleanse_header!
162
- self.columns = category.tabular.header.columns
163
- # Call chained on_first if present
164
- on_first&.call(line)
165
- end
203
+ return schema_header_callback(on_first) if schema.present?
204
+ return first_record_callback(on_first) unless tabular? && tabular.header?
205
+
206
+ lambda do |line|
207
+ next false if line.blank?
208
+
209
+ tabular.parse_header(line)
210
+ cleanse_header!
211
+ self.columns = tabular.header.columns
212
+ warn_repeated_columns
213
+ # Call chained on_first if present
214
+ on_first&.call(line)
215
+ true
216
+ end
217
+ end
218
+
219
+ private
220
+
221
+ # Reports a deprecation warning, see RocketJob::Deprecation, at the line of the application that uploads a file
222
+ # whose header row has columns that are the same key once cleansed, such as `a` and `A`, since each record holds
223
+ # the value of only the last of them, and IOStreams 3.0, which Rocket Job 8 requires, raises for them.
224
+ def warn_repeated_columns
225
+ repeated = columns.compact.tally.select { |_column, count| count > 1 }.keys
226
+ return if repeated.empty?
227
+
228
+ RocketJob::Deprecation.warn(
229
+ "a header row whose columns are the same key, #{repeated.collect(&:inspect).join(', ')}, is deprecated, " \
230
+ "since each record holds the value of only the last of them, and Rocket Job 8 raises: rename the " \
231
+ "columns of the file, or describe them with the schema: of the input category, see " \
232
+ "#{RocketJob::Deprecation::UPGRADE_GUIDE}"
233
+ )
234
+ end
235
+
236
+ # Returns [IOStreams::Tabular] that reads the records of this category by its schema, see Category::Base#schema,
237
+ # and has read the header row that an upload read from the file, see #columns, so that the workers parse each line
238
+ # by it.
239
+ def schema_tabular
240
+ tabular = super
241
+ tabular.read_header(columns) if columns.present? && tabular.header?
242
+ tabular
243
+ end
244
+
245
+ # Returns the lambda that reads the header row of a file with a schema, see #extract_header_callback. Its columns
246
+ # are matched to the fields of the schema, which raises IOStreams::Errors::InvalidHeader when they do not match,
247
+ # and are kept as they are in the file, see #columns. A blank row before it, which IOStreams does not read as a
248
+ # header row, such as a line of only commas, is skipped, as IOStreams skips it when it reads the header row itself.
249
+ #
250
+ # With a schema, `columns` hold the header row of the file uploaded, so an upload reads the header row of its own
251
+ # file, rather than keep the columns of an earlier one, such as those that a job scheduled again copies from its
252
+ # last run, see Plugins::Cron.
253
+ def schema_header_callback(on_first)
254
+ self.columns = nil
255
+ @tabular = nil
256
+ return first_record_callback(on_first) unless tabular.header?
257
+
258
+ lambda do |line|
259
+ tabular.read_header(line)
260
+ next false if tabular.header?
261
+
262
+ self.columns = tabular.header.columns
263
+ # Call chained on_first if present
264
+ on_first&.call(line)
265
+ true
266
+ end
267
+ end
268
+
269
+ # The schema describes the columns of the file, so the options that describe them without one cannot be set with
270
+ # it, see Category::Base#schema.
271
+ def fields_replaced_by_schema
272
+ replaced = super + %i[allowed_columns required_columns].select { |name| public_send(name).present? }
273
+ replaced << :skip_unknown if skip_unknown
274
+ replaced << :header_cleanser if header_cleanser == :none
275
+ replaced
276
+ end
277
+
278
+ # The :array mode cannot be used with a schema, see #mode.
279
+ def schema_replaces_fields
280
+ super
281
+ errors.add(:mode, "cannot be :array with a schema, use :line or :hash") if schema.present? && mode == :array
282
+ end
283
+
284
+ # Returns the lambda that calls the supplied `on_first` with the first record uploaded, which is not stored, see
285
+ # #extract_header_callback, or nil without one.
286
+ def first_record_callback(on_first)
287
+ return unless on_first
288
+
289
+ lambda do |record|
290
+ on_first.call(record)
291
+ true
166
292
  end
167
293
  end
168
294
  end
@@ -29,6 +29,13 @@ module RocketJob
29
29
  slice_class: serializer_class
30
30
  )
31
31
  end
32
+
33
+ private
34
+
35
+ # The schema gives the columns of the file written, see Category::Base#schema.
36
+ def fields_replaced_by_schema
37
+ columns.present? ? super + [:columns] : super
38
+ end
32
39
  end
33
40
  end
34
41
  end
@@ -0,0 +1,51 @@
1
+ module RocketJob
2
+ # Reports the use of a deprecated option or method as a Ruby deprecation warning, see `Kernel#warn`, as IOStreams
3
+ # does, see `IOStreams::Deprecation`:
4
+ # - Only while deprecation warnings are on, see `Warning[:deprecated]`. They are off by default, so production stays
5
+ # quiet, and on in tests run by Minitest or test-unit, or with `ruby -W:deprecated` or `RUBYOPT=-W:deprecated`.
6
+ # - Written to `$stderr`, unless the application routes them elsewhere, such as to its log, or raises them in its
7
+ # tests, by overriding `Warning.warn`.
8
+ #
9
+ # Each warning names the first line of the call stack outside Rocket Job, such as the line of a job class that
10
+ # defines a category with a deprecated option, rather than a line of Rocket Job.
11
+ module Deprecation
12
+ # The directory of the files of this library, whose lines are skipped when finding the line that used it.
13
+ LIB_DIR = "#{File.expand_path('..', __dir__)}/".freeze
14
+
15
+ # Where the upgrade guide says what replaces each deprecated option.
16
+ UPGRADE_GUIDE = "https://rocketjob.reidmorrison.com/upgrading.html#upgrading-to-v71".freeze
17
+
18
+ # Reports that the application used something deprecated, unless deprecation warnings are off, with the message,
19
+ # which says what is deprecated and what to use instead, after "Rocket Job: ", at the first line of the call stack
20
+ # outside this library and the libraries that call it, or without a line when there is none, such as in a worker
21
+ # that builds the job of a Dirmon entry.
22
+ #
23
+ # Returns nil.
24
+ def self.warn(message)
25
+ return unless Warning[:deprecated]
26
+
27
+ dirs = library_dirs
28
+ uplevel = caller_locations(1).index { |location| !library?(location, dirs) }
29
+ # Kernel#warn counts its own caller, this method, as level 0.
30
+ Kernel.warn("Rocket Job: #{message}", uplevel: uplevel && (uplevel + 1), category: :deprecated)
31
+ nil
32
+ end
33
+
34
+ # Returns [Array<String>] the directories whose lines are skipped when finding the line of the application: this
35
+ # library, the other gems, which call it back, such as Active Support's callbacks and Mongoid, and Ruby's own
36
+ # library.
37
+ def self.library_dirs
38
+ gems = Gem.loaded_specs.each_value.filter_map { |spec| "#{spec.full_gem_path}/" unless spec.name == "rocketjob" }
39
+ [LIB_DIR, "#{RbConfig::CONFIG['rubylibdir']}/", *gems]
40
+ end
41
+ private_class_method :library_dirs
42
+
43
+ # Returns [true|false] whether the line of the call stack is in a file of a library, see .library_dirs, or of Ruby
44
+ # itself.
45
+ def self.library?(location, dirs)
46
+ path = (location.absolute_path || location.path).to_s
47
+ path.start_with?("<internal:") || dirs.any? { |dir| path.start_with?(dir) }
48
+ end
49
+ private_class_method :library?
50
+ end
51
+ end
@@ -82,8 +82,18 @@ module RocketJob
82
82
  self.description ||= "Copying to #{target_url}"
83
83
  end
84
84
 
85
+ # Applies the supplied streams to the path, in order. The `none` stream, which `source_streams` and
86
+ # `target_streams` default to, removes the streams before it, so that on its own the file is read or written as
87
+ # it is stored, see `IOStreams::Stream#raw`, rather than with IOStreams' deprecated `stream(:none)`.
85
88
  def apply_streams(path, streams)
86
- streams.each_pair do |stream, args|
89
+ streams = streams.to_a
90
+ none = streams.rindex { |stream, _args| stream.to_s == "none" }
91
+ if none
92
+ streams = streams.drop(none + 1)
93
+ return path.raw if streams.empty?
94
+ end
95
+
96
+ streams.each do |stream, args|
87
97
  stream_args = args.nil? ? {} : decode_args(args)
88
98
  path.stream(stream.to_sym, **stream_args)
89
99
  end
@@ -27,41 +27,42 @@ module RocketJob
27
27
  # MyCronJob.create!(retry_limit: 0)
28
28
  #
29
29
  # Below is a table of the delay for each retry attempt, as well as the _total_ duration spent retrying a job for
30
- # the specified number of retries, excluding actual processing time.
30
+ # the specified number of retries, excluding actual processing time. The delay before retry `n` is
31
+ # `(n - 1)**4 + 5` seconds.
31
32
  #
32
33
  # |---------|------------|----------------|
33
34
  # | Attempt | Delay | Total Duration |
34
35
  # |---------|------------|----------------|
35
- # | 01 | 6.000s | 6.000s |
36
- # | 02 | 21.000s | 27.000s |
37
- # | 03 | 1m 26s | 1m 53s |
38
- # | 04 | 4m 21s | 6m 14s |
39
- # | 05 | 10m 30s | 16m 44s |
40
- # | 06 | 21m 41s | 38m 25s |
41
- # | 07 | 40m 6s | 20h 18m |
42
- # | 08 | 20h 8m | 21h 26m |
43
- # | 09 | 20h 49m | 23h 16m |
44
- # | 10 | 21h 46m | 2h 3m |
45
- # | 11 | 23h 4m | 6h 7m |
46
- # | 12 | 0h 45m | 11h 52m |
47
- # | 13 | 2h 56m | 1d 19h 48m |
48
- # | 14 | 5h 40m | 1d 6h 29m |
49
- # | 15 | 9h 3m | 2d 20h 33m |
50
- # | 16 | 13h 12m | 2d 14h 45m |
51
- # | 17 | 18h 12m | 3d 13h 57m |
52
- # | 18 | 1d 0h 9m | 5d 19h 7m |
53
- # | 19 | 1d 7h 12m | 6d 7h 19m |
54
- # | 20 | 1d 15h 26m | 8d 3h 46m |
55
- # | 21 | 2d 1h 1m | 10d 9h 47m |
56
- # | 22 | 2d 12h 4m | 13d 2h 51m |
57
- # | 23 | 3d 0h 44m | 16d 8h 35m |
58
- # | 24 | 3d 15h 9m | 20d 4h 45m |
59
- # | 25 | 4d 7h 30m | 24d 17h 16 |
60
- # | 26 | 5d 1h 56m | 30d 0h 12m |
61
- # | 27 | 6d 22h 37m | 36d 3h 49m |
62
- # | 28 | 7d 21h 44m | 43d 6h 34m |
63
- # | 29 | 8d 23h 28m | 51d 11h 2m |
64
- # | 30 | 9d 4h 0m | 61d 20h 2m |
36
+ # | 01 | 5.000s | 5.000s |
37
+ # | 02 | 6.000s | 11.000s |
38
+ # | 03 | 21.000s | 32.000s |
39
+ # | 04 | 1m 26s | 1m 58s |
40
+ # | 05 | 4m 21s | 6m 19s |
41
+ # | 06 | 10m 30s | 16m 49s |
42
+ # | 07 | 21m 41s | 38m 30s |
43
+ # | 08 | 40m 6s | 1h 18m |
44
+ # | 09 | 1h 8m | 2h 26m |
45
+ # | 10 | 1h 49m | 4h 16m |
46
+ # | 11 | 2h 46m | 7h 3m |
47
+ # | 12 | 4h 4m | 11h 7m |
48
+ # | 13 | 5h 45m | 16h 52m |
49
+ # | 14 | 7h 56m | 1d 0h 49m |
50
+ # | 15 | 10h 40m | 1d 11h 29m |
51
+ # | 16 | 14h 3m | 2d 1h 33m |
52
+ # | 17 | 18h 12m | 2d 19h 45m |
53
+ # | 18 | 23h 12m | 3d 18h 57m |
54
+ # | 19 | 1d 5h 9m | 5d 0h 7m |
55
+ # | 20 | 1d 12h 12m | 6d 12h 19m |
56
+ # | 21 | 1d 20h 26m | 8d 8h 46m |
57
+ # | 22 | 2d 6h 1m | 10d 14h 47m |
58
+ # | 23 | 2d 17h 4m | 13d 7h 51m |
59
+ # | 24 | 3d 5h 44m | 16d 13h 36m |
60
+ # | 25 | 3d 20h 9m | 20d 9h 45m |
61
+ # | 26 | 4d 12h 30m | 24d 22h 16m |
62
+ # | 27 | 5d 6h 56m | 30d 5h 12m |
63
+ # | 28 | 6d 3h 37m | 36d 8h 50m |
64
+ # | 29 | 7d 2h 44m | 43d 11h 34m |
65
+ # | 30 | 8d 4h 28m | 51d 16h 2m |
65
66
  # |---------|------------|----------------|
66
67
  module Retry
67
68
  extend ActiveSupport::Concern
@@ -70,7 +71,7 @@ module RocketJob
70
71
  after_fail :rocket_job_retry
71
72
 
72
73
  # Maximum number of times to retry this job.
73
- # The default of 25 is almost 25 days of retries.
74
+ # The default of 25 retries spans about 20 days, see the table above.
74
75
  field :retry_limit, type: Integer, default: 25, class_attribute: true, user_editable: true, copy_on_restart: true
75
76
 
76
77
  # List of times when this job failed
@@ -115,7 +116,7 @@ module RocketJob
115
116
  #
116
117
  # For example, to see the durations for the first 25 retries:
117
118
  # count = 25
118
- # intervals = (1..count).map { |attempt| attempt**4 + 5 }
119
+ # intervals = (0...count).map { |failures| (failures**4) + 5 }
119
120
  #
120
121
  # Display the above intervals as human readable durations:
121
122
  # intervals.map { |seconds| RocketJob.seconds_as_duration(seconds) }
@@ -124,7 +125,7 @@ module RocketJob
124
125
  # RocketJob.seconds_as_duration(intervals.sum)
125
126
  #
126
127
  # Or, to see the total durations based on the number of retries:
127
- # (0..count).map{|i| "#{i+1} ==> #{RocketJob.seconds_as_duration(intervals[0..i].sum)}"}
128
+ # (0...count).map { |i| "#{i + 1} ==> #{RocketJob.seconds_as_duration(intervals[0..i].sum)}" }
128
129
  def rocket_job_retry_seconds_to_delay
129
130
  (rocket_job_failure_count**4) + 5
130
131
  end
@@ -9,8 +9,9 @@ module RocketJob
9
9
  #
10
10
  # Parameters
11
11
  # on_first: [Proc]
12
- # Block to call on the first line only, instead of storing in the slice.
13
- # Useful for extracting the header row
12
+ # Called with each line before the first that is stored, instead of storing it, until it returns true.
13
+ # Useful for extracting the header row, after any blank lines before it, see
14
+ # Category::Input#extract_header_callback.
14
15
  # Default: nil
15
16
  #
16
17
  # slice_size: [Integer]
@@ -42,8 +43,7 @@ module RocketJob
42
43
 
43
44
  def <<(line)
44
45
  if @on_first
45
- @on_first.call(line)
46
- @on_first = nil
46
+ @on_first = nil if @on_first.call(line)
47
47
  return self
48
48
  end
49
49
  @slice << line
@@ -1,3 +1,3 @@
1
1
  module RocketJob
2
- VERSION = "7.0.0".freeze
2
+ VERSION = "7.1.0".freeze
3
3
  end
data/lib/rocketjob.rb CHANGED
@@ -23,6 +23,7 @@ module RocketJob
23
23
  autoload :ActiveWorker, "rocket_job/active_worker"
24
24
  autoload :Batch, "rocket_job/batch"
25
25
  autoload :CLI, "rocket_job/cli"
26
+ autoload :Deprecation, "rocket_job/deprecation"
26
27
  autoload :DirmonEntry, "rocket_job/dirmon_entry"
27
28
  autoload :Event, "rocket_job/event"
28
29
  autoload :Heartbeat, "rocket_job/heartbeat"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rocketjob
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.0
4
+ version: 7.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Reid Morrison
@@ -57,14 +57,14 @@ dependencies:
57
57
  requirements:
58
58
  - - "~>"
59
59
  - !ruby/object:Gem::Version
60
- version: '2.0'
60
+ version: '2.2'
61
61
  type: :runtime
62
62
  prerelease: false
63
63
  version_requirements: !ruby/object:Gem::Requirement
64
64
  requirements:
65
65
  - - "~>"
66
66
  - !ruby/object:Gem::Version
67
- version: '2.0'
67
+ version: '2.2'
68
68
  - !ruby/object:Gem::Dependency
69
69
  name: mongoid
70
70
  requirement: !ruby/object:Gem::Requirement
@@ -145,6 +145,7 @@ files:
145
145
  - lib/rocket_job/category/output.rb
146
146
  - lib/rocket_job/cli.rb
147
147
  - lib/rocket_job/config.rb
148
+ - lib/rocket_job/deprecation.rb
148
149
  - lib/rocket_job/dirmon_entry.rb
149
150
  - lib/rocket_job/event.rb
150
151
  - lib/rocket_job/extensions/iostreams/path.rb
@@ -226,7 +227,7 @@ metadata:
226
227
  changelog_uri: https://github.com/reidmorrison/rocketjob/blob/main/CHANGELOG.md
227
228
  documentation_uri: https://rocketjob.reidmorrison.com
228
229
  homepage_uri: https://rocketjob.reidmorrison.com
229
- source_code_uri: https://github.com/reidmorrison/rocketjob/tree/v7.0.0
230
+ source_code_uri: https://github.com/reidmorrison/rocketjob/tree/v7.1.0
230
231
  rubygems_mfa_required: 'true'
231
232
  rdoc_options: []
232
233
  require_paths: