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
|
@@ -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
|
-
#
|
|
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
|
|
50
|
-
# This is the default
|
|
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
|
-
#
|
|
61
|
-
#
|
|
62
|
-
#
|
|
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
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
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
|
-
|
|
137
|
-
|
|
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.
|
|
164
|
+
path.encoding("UTF-8", cleaner: :printable, replace: replace)
|
|
141
165
|
end
|
|
142
166
|
path
|
|
143
167
|
end
|
|
144
168
|
|
|
145
|
-
#
|
|
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
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
data/lib/rocket_job/config.rb
CHANGED
|
@@ -81,6 +81,11 @@ module RocketJob
|
|
|
81
81
|
logger.debug "Reading Mongo configuration from: #{config_file}"
|
|
82
82
|
::Mongoid.load!(config_file, environment)
|
|
83
83
|
|
|
84
|
+
# Mongoid 9 requires `Time.zone` to be set in order to mongoize Date and
|
|
85
|
+
# Time values. When running outside of Rails it may be unset, so default
|
|
86
|
+
# it here without overriding an explicit application setting.
|
|
87
|
+
::Time.zone_default ||= ::ActiveSupport::TimeZone["UTC"]
|
|
88
|
+
|
|
84
89
|
config_file =
|
|
85
90
|
if encryption_file_name
|
|
86
91
|
Pathname.new(encryption_file_name)
|
|
@@ -100,7 +105,7 @@ module RocketJob
|
|
|
100
105
|
def self.filter
|
|
101
106
|
raise(ArgumentError, "Cannot supply both an include_filter and an exclude_filter") if include_filter && exclude_filter
|
|
102
107
|
|
|
103
|
-
filter
|
|
108
|
+
filter = where_filter
|
|
104
109
|
(filter ||= {})["_type"] = include_filter if include_filter
|
|
105
110
|
(filter ||= {})["_type"] = {"$not" => exclude_filter} if exclude_filter
|
|
106
111
|
filter&.dup
|
|
@@ -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
|
|
@@ -286,14 +286,16 @@ module RocketJob
|
|
|
286
286
|
klass = job_class
|
|
287
287
|
return unless klass
|
|
288
288
|
|
|
289
|
-
|
|
290
|
-
|
|
289
|
+
# Mongoid 9 returns Hash field keys as Strings, earlier versions as Symbols.
|
|
290
|
+
properties.each_pair do |raw_key, value|
|
|
291
|
+
k = raw_key.to_sym
|
|
292
|
+
next if klass.public_method_defined?(:"#{k}=")
|
|
291
293
|
|
|
292
294
|
if %i[output_categories input_categories].include?(k)
|
|
293
295
|
category_class = k == :input_categories ? RocketJob::Category::Input : RocketJob::Category::Output
|
|
294
|
-
|
|
296
|
+
value.each do |category|
|
|
295
297
|
category.each_pair do |key, _value|
|
|
296
|
-
next if category_class.public_method_defined?("#{key}="
|
|
298
|
+
next if category_class.public_method_defined?(:"#{key}=")
|
|
297
299
|
|
|
298
300
|
errors.add(
|
|
299
301
|
:properties,
|
data/lib/rocket_job/event.rb
CHANGED
|
@@ -12,6 +12,11 @@ module RocketJob
|
|
|
12
12
|
|
|
13
13
|
ALL_EVENTS = "*".freeze
|
|
14
14
|
|
|
15
|
+
# MongoDB OperationFailure codes that are safe to ignore when concurrently
|
|
16
|
+
# creating the polling collection and its TTL index:
|
|
17
|
+
# 48 NamespaceExists, 85 IndexOptionsConflict, 86 IndexKeySpecsConflict.
|
|
18
|
+
IGNORABLE_CREATE_CODES = [48, 85, 86].freeze
|
|
19
|
+
|
|
15
20
|
# Capped collection long polling interval.
|
|
16
21
|
class_attribute :long_poll_seconds, instance_accessor: false
|
|
17
22
|
self.long_poll_seconds = 300
|
|
@@ -23,6 +28,28 @@ module RocketJob
|
|
|
23
28
|
class_attribute :capped_collection_size, instance_accessor: false
|
|
24
29
|
self.capped_collection_size = 128 * 1024 * 1024
|
|
25
30
|
|
|
31
|
+
# Event listener strategy:
|
|
32
|
+
# :capped - Tail a capped collection with a tailable cursor (default).
|
|
33
|
+
# Lowest latency, but requires a data store that supports capped
|
|
34
|
+
# collections and tailable cursors (i.e. a real MongoDB server).
|
|
35
|
+
# :polling - Poll a regular collection for new events. Slightly higher latency
|
|
36
|
+
# (bounded by `poll_interval`), but works on any MongoDB-compatible
|
|
37
|
+
# store, including AWS DocumentDB which has no capped collections.
|
|
38
|
+
class_attribute :listener_strategy, instance_accessor: false
|
|
39
|
+
self.listener_strategy = :capped
|
|
40
|
+
|
|
41
|
+
# Polling strategy: seconds to wait between polls for new events.
|
|
42
|
+
class_attribute :poll_interval, instance_accessor: false
|
|
43
|
+
self.poll_interval = 1
|
|
44
|
+
|
|
45
|
+
# Polling strategy: seconds an event is retained before a TTL index removes it.
|
|
46
|
+
# Must comfortably exceed the longest expected listener downtime so that a
|
|
47
|
+
# restarting server can still recover events published while it was away.
|
|
48
|
+
#
|
|
49
|
+
# Default: 1 hour.
|
|
50
|
+
class_attribute :event_retention_seconds, instance_accessor: false
|
|
51
|
+
self.event_retention_seconds = 60 * 60
|
|
52
|
+
|
|
26
53
|
# Mandatory Event Name
|
|
27
54
|
# Examples:
|
|
28
55
|
# '/rocket_job/config'
|
|
@@ -81,14 +108,23 @@ module RocketJob
|
|
|
81
108
|
@subscribers.each_value { |v| v.delete_if { |i| i.object_id == handle } }
|
|
82
109
|
end
|
|
83
110
|
|
|
84
|
-
# Indefinitely
|
|
111
|
+
# Indefinitely watch for new events, dispatching each to its subscribers.
|
|
85
112
|
# time: the start time from which to start looking for new events.
|
|
86
113
|
def self.listener(time: @load_time)
|
|
87
114
|
Thread.current.name = "rocketjob event"
|
|
88
|
-
create_capped_collection
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
116
|
+
case listener_strategy
|
|
117
|
+
when :capped
|
|
118
|
+
create_capped_collection
|
|
119
|
+
logger.info("Event listener started (capped collection)")
|
|
120
|
+
tail_capped_collection(time) { |event| process_event(event) }
|
|
121
|
+
when :polling
|
|
122
|
+
create_polling_collection
|
|
123
|
+
logger.info("Event listener started (polling, interval: #{poll_interval}s)")
|
|
124
|
+
poll_collection(time) { |event| process_event(event) }
|
|
125
|
+
else
|
|
126
|
+
raise(ArgumentError, "Unknown RocketJob::Event.listener_strategy: #{listener_strategy.inspect}")
|
|
127
|
+
end
|
|
92
128
|
rescue Exception => e
|
|
93
129
|
logger.error("#listener Event listener is terminating due to unhandled exception", e)
|
|
94
130
|
raise(e)
|
|
@@ -134,6 +170,50 @@ module RocketJob
|
|
|
134
170
|
retry
|
|
135
171
|
end
|
|
136
172
|
|
|
173
|
+
# Create the regular (non-capped) collection used by the polling strategy,
|
|
174
|
+
# along with a TTL index that expires old events.
|
|
175
|
+
#
|
|
176
|
+
# Safe to call repeatedly: it never drops data, and tolerates the collection
|
|
177
|
+
# and index already existing.
|
|
178
|
+
def self.create_polling_collection
|
|
179
|
+
collection.client[collection_name].create unless collection_exists?
|
|
180
|
+
collection.indexes.create_one({created_at: 1}, expire_after: event_retention_seconds)
|
|
181
|
+
rescue Mongo::Error::OperationFailure => e
|
|
182
|
+
# Ignore "collection already exists" and "index already exists" races between
|
|
183
|
+
# multiple servers starting at once. Anything else is a real error.
|
|
184
|
+
raise(e) unless IGNORABLE_CREATE_CODES.include?(e.code)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Indefinitely poll a regular collection for new events.
|
|
188
|
+
# time: the start time from which to start looking for new events.
|
|
189
|
+
#
|
|
190
|
+
# After the first poll the `_id` of the last event seen is used as the
|
|
191
|
+
# watermark, which is monotonic and unique, so events sharing a `created_at`
|
|
192
|
+
# timestamp are never skipped.
|
|
193
|
+
def self.poll_collection(time, &block)
|
|
194
|
+
last_id = nil
|
|
195
|
+
loop do
|
|
196
|
+
last_id = poll_once(time, last_id, &block)
|
|
197
|
+
sleep(poll_interval)
|
|
198
|
+
end
|
|
199
|
+
rescue Mongo::Error::SocketError, Mongo::Error::SocketTimeoutError, Mongo::Error::OperationFailure, Timeout::Error => e
|
|
200
|
+
logger.info("Polling failed, retrying: #{e.class.name} #{e.message}")
|
|
201
|
+
sleep(poll_interval)
|
|
202
|
+
retry
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Perform a single polling pass, yielding every event newer than the watermark
|
|
206
|
+
# and returning the new watermark (the `_id` of the last event seen, or the
|
|
207
|
+
# supplied `last_id` when no new events were found).
|
|
208
|
+
def self.poll_once(time, last_id = nil)
|
|
209
|
+
filter = last_id ? {_id: {"$gt" => last_id}} : {created_at: {"$gt" => time}}
|
|
210
|
+
collection.find(filter).sort(_id: 1).each do |doc|
|
|
211
|
+
last_id = doc["_id"]
|
|
212
|
+
yield(Mongoid::Factory.from_db(Event, doc))
|
|
213
|
+
end
|
|
214
|
+
last_id
|
|
215
|
+
end
|
|
216
|
+
|
|
137
217
|
# Process a new event, calling registered subscribers.
|
|
138
218
|
def self.process_event(event)
|
|
139
219
|
logger.info("Event Received", event.attributes)
|
|
@@ -11,7 +11,7 @@ module RocketJob
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def collection(parent = nil)
|
|
14
|
-
@collection_name ? mongo_client[@collection_name] : super
|
|
14
|
+
@collection_name ? mongo_client[@collection_name] : super
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def collection_name
|
|
@@ -31,5 +31,5 @@ module RocketJob
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
Mongoid::Criteria.include(RocketJob::MongoidClients::Options)
|
|
35
|
+
Mongoid::Document.include(RocketJob::MongoidClients::Options)
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
::Mongoid::Contextual::Mongo
|
|
2
1
|
module Mongoid
|
|
3
2
|
module Contextual
|
|
4
3
|
class Mongo
|
|
5
4
|
def initialize(criteria)
|
|
6
5
|
@criteria = criteria
|
|
7
6
|
@klass = criteria.klass
|
|
8
|
-
|
|
9
|
-
#
|
|
7
|
+
# Only line changed from the Mongoid implementation is here: fetch the
|
|
8
|
+
# collection from the criteria so a custom collection_name is honored
|
|
9
|
+
# rather than always using @klass.collection.
|
|
10
10
|
# @collection = @klass.collection
|
|
11
11
|
@collection = criteria.collection
|
|
12
|
-
|
|
13
12
|
criteria.send(:merge_type_selection)
|
|
14
13
|
@view = collection.find(criteria.selector, session: _session)
|
|
15
14
|
apply_options
|
|
@@ -4,11 +4,11 @@ module RocketJob
|
|
|
4
4
|
# Don't convert to Mongoid::Factory since it conflicts with Mongoid use.
|
|
5
5
|
module MongoidFactory
|
|
6
6
|
def from_db(klass, attributes = nil, criteria = nil, selected_fields = nil)
|
|
7
|
-
obj = super
|
|
7
|
+
obj = super
|
|
8
8
|
obj.collection_name = criteria.collection_name if criteria
|
|
9
9
|
obj
|
|
10
10
|
end
|
|
11
11
|
end
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Mongoid::Factory.extend(RocketJob::MongoidFactory)
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
require "psych/visitors/yaml_tree"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
module Psych
|
|
4
|
+
module Visitors
|
|
5
|
+
class YAMLTree
|
|
6
|
+
# Serialize IOStream path as a string
|
|
7
|
+
def visit_IOStreams_Path(o)
|
|
8
|
+
visit_String(o.to_s)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
7
11
|
end
|
|
8
12
|
end
|
|
@@ -4,7 +4,7 @@ module ActiveJob
|
|
|
4
4
|
#
|
|
5
5
|
# Ruby's missing batch system.
|
|
6
6
|
#
|
|
7
|
-
# Read more about Rocket Job {here}[
|
|
7
|
+
# Read more about Rocket Job {here}[https://rocketjob.reidmorrison.com].
|
|
8
8
|
#
|
|
9
9
|
# To use Rocket Job set the queue_adapter config to +:rocket_job+.
|
|
10
10
|
#
|
|
@@ -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
|
|
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
|
|
@@ -85,7 +85,9 @@ module RocketJob
|
|
|
85
85
|
file_names[key] = size if size
|
|
86
86
|
end
|
|
87
87
|
rescue StandardError => e
|
|
88
|
-
logger.error(
|
|
88
|
+
logger.error(
|
|
89
|
+
"Dirmon Entry: #{dirmon_entry.id} failed. Moved to `failed` state to prevent processing again without manual intervention.", e
|
|
90
|
+
)
|
|
89
91
|
dirmon_entry.fail(worker_name, e)
|
|
90
92
|
dirmon_entry.save(validate: false)
|
|
91
93
|
end
|
|
@@ -105,7 +105,7 @@ module RocketJob
|
|
|
105
105
|
|
|
106
106
|
# Add a new output category and collect output for it.
|
|
107
107
|
def add_output_category(**args)
|
|
108
|
-
|
|
108
|
+
output_categories << RocketJob::Category::Output.new(**args)
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
private
|