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
@@ -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
@@ -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 = where_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
- properties.each_pair do |k, _v|
290
- next if klass.public_method_defined?("#{k}=".to_sym)
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
- properties[k].each do |category|
296
+ value.each do |category|
295
297
  category.each_pair do |key, _value|
296
- next if category_class.public_method_defined?("#{key}=".to_sym)
298
+ next if category_class.public_method_defined?(:"#{key}=")
297
299
 
298
300
  errors.add(
299
301
  :properties,
@@ -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 tail the capped collection looking for new events.
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
- logger.info("Event listener started")
91
- tail_capped_collection(time) { |event| process_event(event) }
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)
@@ -4,6 +4,7 @@ module Mongo
4
4
  class Monitoring
5
5
  class CommandLogSubscriber
6
6
  include SemanticLogger::Loggable
7
+
7
8
  logger.name = "Mongo"
8
9
 
9
10
  undef :started
@@ -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(parent)
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
- ::Mongoid::Criteria.include(RocketJob::MongoidClients::Options)
35
- ::Mongoid::Document.include(RocketJob::MongoidClients::Options)
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
- @cache = criteria.options[:cache]
9
- # Only line changed is here, get collection name from criteria, not @klass
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(klass, attributes, criteria, selected_fields)
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
- ::Mongoid::Factory.extend(RocketJob::MongoidFactory)
14
+ Mongoid::Factory.extend(RocketJob::MongoidFactory)
@@ -1,8 +1,12 @@
1
1
  require "psych/visitors/yaml_tree"
2
2
 
3
- class Psych::Visitors::YAMLTree
4
- # Serialize IOStream path as a string
5
- def visit_IOStreams_Path(o)
6
- visit_String(o.to_s)
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}[http://rocketjob.io].
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
  #
@@ -1,7 +1,7 @@
1
1
  module RocketJob
2
2
  module Jobs
3
3
  # Used to wrap an Active Job
4
- class ActiveJob < RocketJob::Job #:nodoc:
4
+ class ActiveJob < RocketJob::Job # :nodoc:
5
5
  field :data, type: Hash
6
6
  field :active_job_id, type: String
7
7
  field :active_job_class, type: String
@@ -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
@@ -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("Dirmon Entry: #{dirmon_entry.id} failed. Moved to `failed` state to prevent processing again without manual intervention.", e)
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
- self.output_categories << RocketJob::Category::Output.new(**args)
108
+ output_categories << RocketJob::Category::Output.new(**args)
109
109
  end
110
110
 
111
111
  private