maintenance_tasks 1.3.0 → 1.7.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +288 -45
  3. data/app/controllers/maintenance_tasks/tasks_controller.rb +7 -2
  4. data/app/helpers/maintenance_tasks/application_helper.rb +1 -0
  5. data/app/helpers/maintenance_tasks/tasks_helper.rb +19 -14
  6. data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +44 -23
  7. data/app/models/maintenance_tasks/application_record.rb +1 -0
  8. data/app/models/maintenance_tasks/csv_collection_builder.rb +33 -0
  9. data/app/models/maintenance_tasks/null_collection_builder.rb +31 -0
  10. data/app/models/maintenance_tasks/progress.rb +8 -3
  11. data/app/models/maintenance_tasks/run.rb +224 -18
  12. data/app/models/maintenance_tasks/runner.rb +26 -7
  13. data/app/models/maintenance_tasks/runs_page.rb +1 -0
  14. data/app/models/maintenance_tasks/task.rb +225 -0
  15. data/app/models/maintenance_tasks/task_data.rb +24 -3
  16. data/app/validators/maintenance_tasks/run_status_validator.rb +2 -2
  17. data/app/views/maintenance_tasks/runs/_arguments.html.erb +22 -0
  18. data/app/views/maintenance_tasks/runs/_csv.html.erb +5 -0
  19. data/app/views/maintenance_tasks/runs/_run.html.erb +18 -1
  20. data/app/views/maintenance_tasks/runs/info/_custom.html.erb +0 -0
  21. data/app/views/maintenance_tasks/runs/info/_errored.html.erb +0 -2
  22. data/app/views/maintenance_tasks/runs/info/_paused.html.erb +2 -2
  23. data/app/views/maintenance_tasks/runs/info/_running.html.erb +3 -5
  24. data/app/views/maintenance_tasks/tasks/_custom.html.erb +0 -0
  25. data/app/views/maintenance_tasks/tasks/_task.html.erb +19 -1
  26. data/app/views/maintenance_tasks/tasks/index.html.erb +2 -2
  27. data/app/views/maintenance_tasks/tasks/show.html.erb +37 -2
  28. data/config/routes.rb +1 -0
  29. data/db/migrate/20201211151756_create_maintenance_tasks_runs.rb +1 -0
  30. data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -0
  31. data/db/migrate/20210517131953_add_arguments_to_maintenance_tasks_runs.rb +7 -0
  32. data/db/migrate/20211210152329_add_lock_version_to_maintenance_tasks_runs.rb +8 -0
  33. data/lib/generators/maintenance_tasks/install_generator.rb +1 -0
  34. data/lib/generators/maintenance_tasks/templates/task.rb.tt +3 -1
  35. data/lib/maintenance_tasks/cli.rb +11 -4
  36. data/lib/maintenance_tasks/engine.rb +15 -1
  37. data/lib/maintenance_tasks.rb +14 -1
  38. data/lib/patches/active_record_batch_enumerator.rb +23 -0
  39. metadata +15 -11
  40. data/app/models/maintenance_tasks/csv_collection.rb +0 -33
  41. data/app/tasks/maintenance_tasks/task.rb +0 -133
  42. data/app/views/maintenance_tasks/runs/_info.html.erb +0 -16
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module MaintenanceTasks
3
4
  # Concern that holds the behaviour of the job that runs the tasks. It is
4
5
  # included in {TaskJob} and if MaintenanceTasks.job is overridden, it must be
@@ -11,8 +12,8 @@ module MaintenanceTasks
11
12
  before_perform(:before_perform)
12
13
 
13
14
  on_start(:on_start)
14
- on_complete(:on_complete)
15
15
  on_shutdown(:on_shutdown)
16
+ on_complete(:on_complete)
16
17
 
17
18
  after_perform(:after_perform)
18
19
 
@@ -32,21 +33,46 @@ module MaintenanceTasks
32
33
  def build_enumerator(_run, cursor:)
33
34
  cursor ||= @run.cursor
34
35
  collection = @task.collection
36
+ @enumerator = nil
35
37
 
36
38
  collection_enum = case collection
37
39
  when ActiveRecord::Relation
38
40
  enumerator_builder.active_record_on_records(collection, cursor: cursor)
41
+ when ActiveRecord::Batches::BatchEnumerator
42
+ if collection.start || collection.finish
43
+ raise ArgumentError, <<~MSG.squish
44
+ #{@task.class.name}#collection cannot support
45
+ a batch enumerator with the "start" or "finish" options.
46
+ MSG
47
+ end
48
+ # For now, only support automatic count based on the enumerator for
49
+ # batches
50
+ @enumerator = enumerator_builder.active_record_on_batch_relations(
51
+ collection.relation,
52
+ cursor: cursor,
53
+ batch_size: collection.batch_size,
54
+ )
39
55
  when Array
40
56
  enumerator_builder.build_array_enumerator(collection, cursor: cursor)
41
57
  when CSV
42
58
  JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
43
59
  else
44
- raise ArgumentError, "#{@task.class.name}#collection must be either "\
45
- "an Active Record Relation, Array, or CSV."
60
+ raise ArgumentError, <<~MSG.squish
61
+ #{@task.class.name}#collection must be either an
62
+ Active Record Relation, ActiveRecord::Batches::BatchEnumerator,
63
+ Array, or CSV.
64
+ MSG
46
65
  end
66
+ throttle_enumerator(collection_enum)
67
+ end
47
68
 
69
+ def throttle_enumerator(collection_enum)
48
70
  @task.throttle_conditions.reduce(collection_enum) do |enum, condition|
49
- enumerator_builder.build_throttle_enumerator(enum, **condition)
71
+ enumerator_builder.build_throttle_enumerator(
72
+ enum,
73
+ throttle_on: condition[:throttle_on],
74
+ backoff: condition[:backoff].call
75
+ )
50
76
  end
51
77
  end
52
78
 
@@ -71,13 +97,12 @@ module MaintenanceTasks
71
97
 
72
98
  def before_perform
73
99
  @run = arguments.first
74
- @task = Task.named(@run.task_name).new
75
- if @task.respond_to?(:csv_content=)
100
+ @task = @run.task
101
+ if @task.has_csv_content?
76
102
  @task.csv_content = @run.csv_file.download
77
103
  end
78
- @run.job_id = job_id
79
104
 
80
- @run.running! unless @run.stopping?
105
+ @run.running
81
106
 
82
107
  @ticker = Ticker.new(MaintenanceTasks.ticker_delay) do |ticks, duration|
83
108
  @run.persist_progress(ticks, duration)
@@ -85,26 +110,21 @@ module MaintenanceTasks
85
110
  end
86
111
 
87
112
  def on_start
88
- @run.update!(started_at: Time.now, tick_total: @task.count)
89
- end
90
-
91
- def on_complete
92
- @run.status = :succeeded
93
- @run.ended_at = Time.now
113
+ count = @task.count
114
+ count = @enumerator&.size if count == :no_count
115
+ @run.start(count)
94
116
  end
95
117
 
96
118
  def on_shutdown
97
- if @run.cancelling?
98
- @run.status = :cancelled
99
- @run.ended_at = Time.now
100
- else
101
- @run.status = @run.pausing? ? :paused : :interrupted
102
- @run.cursor = cursor_position
103
- end
104
-
119
+ @run.job_shutdown
120
+ @run.cursor = cursor_position
105
121
  @ticker.persist
106
122
  end
107
123
 
124
+ def on_complete
125
+ @run.complete
126
+ end
127
+
108
128
  # We are reopening a private part of Job Iteration's API here, so we should
109
129
  # ensure the method is still defined upstream. This way, in the case where
110
130
  # the method changes upstream, we catch it at load time instead of at
@@ -123,7 +143,7 @@ module MaintenanceTasks
123
143
  end
124
144
 
125
145
  def after_perform
126
- @run.save!
146
+ @run.persist_transition
127
147
  if defined?(@reenqueue_iteration_job) && @reenqueue_iteration_job
128
148
  reenqueue_iteration_job(should_ignore: false)
129
149
  end
@@ -144,6 +164,7 @@ module MaintenanceTasks
144
164
  task_context = {}
145
165
  end
146
166
  errored_element = @errored_element if defined?(@errored_element)
167
+ ensure
147
168
  MaintenanceTasks.error_handler.call(error, task_context, errored_element)
148
169
  end
149
170
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module MaintenanceTasks
3
4
  # Base class for all records used by this engine.
4
5
  #
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module MaintenanceTasks
6
+ # Strategy for building a Task that processes CSV files.
7
+ #
8
+ # @api private
9
+ class CsvCollectionBuilder
10
+ # Defines the collection to be iterated over, based on the provided CSV.
11
+ #
12
+ # @return [CSV] the CSV object constructed from the specified CSV content,
13
+ # with headers.
14
+ def collection(task)
15
+ CSV.new(task.csv_content, headers: true)
16
+ end
17
+
18
+ # The number of rows to be processed. Excludes the header row from the
19
+ # count and assumes a trailing newline is at the end of the CSV file.
20
+ # Note that this number is an approximation based on the number of
21
+ # newlines.
22
+ #
23
+ # @return [Integer] the approximate number of rows to process.
24
+ def count(task)
25
+ task.csv_content.count("\n") - 1
26
+ end
27
+
28
+ # Return that the Task processes CSV content.
29
+ def has_csv_content?
30
+ true
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base strategy for building a collection-based Task to be performed.
5
+ class NullCollectionBuilder
6
+ # Placeholder method to raise in case a subclass fails to implement the
7
+ # expected instance method.
8
+ #
9
+ # @raise [NotImplementedError] with a message advising subclasses to
10
+ # implement an override for this method.
11
+ def collection(task)
12
+ raise NoMethodError, "#{task.class.name} must implement `collection`."
13
+ end
14
+
15
+ # Total count of iterations to be performed.
16
+ #
17
+ # Tasks override this method to define the total amount of iterations
18
+ # expected at the start of the run. Return +nil+ if the amount is
19
+ # undefined, or counting would be prohibitive for your database.
20
+ #
21
+ # @return [Integer, nil]
22
+ def count(task)
23
+ :no_count
24
+ end
25
+
26
+ # Return that the Task does not process CSV content.
27
+ def has_csv_content?
28
+ false
29
+ end
30
+ end
31
+ end
@@ -50,14 +50,19 @@ module MaintenanceTasks
50
50
  def text
51
51
  count = @run.tick_count
52
52
  total = @run.tick_total
53
+
53
54
  if !total?
54
- "Processed #{pluralize(count, "item")}."
55
+ "Processed #{number_to_delimited(count)} "\
56
+ "#{"item".pluralize(count)}."
55
57
  elsif over_total?
56
- "Processed #{pluralize(count, "item")} (expected #{total})."
58
+ "Processed #{number_to_delimited(count)} "\
59
+ "#{"item".pluralize(count)} "\
60
+ "(expected #{number_to_delimited(total)})."
57
61
  else
58
62
  percentage = 100.0 * count / total
59
63
 
60
- "Processed #{count} out of #{pluralize(total, "item")} "\
64
+ "Processed #{number_to_delimited(count)} out of "\
65
+ "#{number_to_delimited(total)} #{"item".pluralize(total)} "\
61
66
  "(#{number_to_percentage(percentage, precision: 0)})."
62
67
  end
63
68
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module MaintenanceTasks
3
4
  # Model that persists information related to a task being run from the UI.
4
5
  #
@@ -25,8 +26,13 @@ module MaintenanceTasks
25
26
  :cancelling,
26
27
  :interrupted,
27
28
  ]
29
+ STOPPING_STATUSES = [
30
+ :pausing,
31
+ :cancelling,
32
+ ]
28
33
  COMPLETED_STATUSES = [:succeeded, :errored, :cancelled]
29
34
  COMPLETED_RUNS_LIMIT = 10
35
+ STUCK_TASK_TIMEOUT = 5.minutes
30
36
 
31
37
  enum status: STATUSES.to_h { |status| [status, status.to_s] }
32
38
 
@@ -34,10 +40,12 @@ module MaintenanceTasks
34
40
  Task.available_tasks.map(&:to_s)
35
41
  } }
36
42
  validate :csv_attachment_presence, on: :create
43
+ validate :validate_task_arguments, on: :create
37
44
 
38
45
  attr_readonly :task_name
39
46
 
40
47
  serialize :backtrace
48
+ serialize :arguments, JSON
41
49
 
42
50
  scope :active, -> { where(status: ACTIVE_STATUSES) }
43
51
 
@@ -58,9 +66,40 @@ module MaintenanceTasks
58
66
 
59
67
  # Sets the run status to enqueued, making sure the transition is validated
60
68
  # in case it's already enqueued.
69
+ #
70
+ # Rescues and retries status transition if an ActiveRecord::StaleObjectError
71
+ # is encountered.
61
72
  def enqueued!
62
73
  status_will_change!
63
74
  super
75
+ rescue ActiveRecord::StaleObjectError
76
+ reload_status
77
+ retry
78
+ end
79
+
80
+ CALLBACKS_TRANSITION = {
81
+ cancelled: :cancel,
82
+ interrupted: :interrupt,
83
+ paused: :pause,
84
+ succeeded: :complete,
85
+ }.transform_keys(&:to_s)
86
+ private_constant :CALLBACKS_TRANSITION
87
+
88
+ # Saves the run, persisting the transition of its status, and all other
89
+ # changes to the object.
90
+ def persist_transition
91
+ save!
92
+ callback = CALLBACKS_TRANSITION[status]
93
+ run_task_callbacks(callback) if callback
94
+ rescue ActiveRecord::StaleObjectError
95
+ success = succeeded?
96
+ reload_status
97
+ if success
98
+ self.status = :succeeded
99
+ else
100
+ job_shutdown
101
+ end
102
+ retry
64
103
  end
65
104
 
66
105
  # Increments +tick_count+ by +number_of_ticks+ and +time_running+ by
@@ -78,33 +117,53 @@ module MaintenanceTasks
78
117
  time_running: duration,
79
118
  touch: true
80
119
  )
120
+ if locking_enabled?
121
+ locking_column = self.class.locking_column
122
+ self[locking_column] += 1
123
+ clear_attribute_change(locking_column)
124
+ end
81
125
  end
82
126
 
83
127
  # Marks the run as errored and persists the error data.
84
128
  #
85
129
  # @param error [StandardError] the Error being persisted.
86
130
  def persist_error(error)
131
+ self.started_at ||= Time.now
87
132
  update!(
88
133
  status: :errored,
89
134
  error_class: error.class.to_s,
90
135
  error_message: error.message,
91
- backtrace: Rails.backtrace_cleaner.clean(error.backtrace),
136
+ backtrace: MaintenanceTasks.backtrace_cleaner.clean(error.backtrace),
92
137
  ended_at: Time.now,
93
138
  )
139
+ run_task_callbacks(:error)
140
+ rescue ActiveRecord::StaleObjectError
141
+ reload_status
142
+ retry
94
143
  end
95
144
 
96
- # Refreshes just the status attribute on the Active Record object, and
97
- # ensures ActiveModel::Dirty does not mark the object as changed.
145
+ # Refreshes the status and lock version attributes on the Active Record
146
+ # object, and ensures ActiveModel::Dirty doesn't mark the object as changed.
147
+ #
98
148
  # This allows us to get the Run's most up-to-date status without needing
99
149
  # to reload the entire record.
100
150
  #
101
151
  # @return [MaintenanceTasks::Run] the Run record with its updated status.
102
152
  def reload_status
103
- updated_status = Run.uncached do
104
- Run.where(id: id).pluck(:status).first
153
+ columns_to_reload = if locking_enabled?
154
+ [:status, self.class.locking_column]
155
+ else
156
+ [:status]
105
157
  end
158
+ updated_status, updated_lock_version = self.class.uncached do
159
+ self.class.where(id: id).pluck(*columns_to_reload).first
160
+ end
161
+
106
162
  self.status = updated_status
107
- clear_attribute_changes([:status])
163
+ if updated_lock_version
164
+ self[self.class.locking_column] = updated_lock_version
165
+ end
166
+ clear_attribute_changes(columns_to_reload)
108
167
  self
109
168
  end
110
169
 
@@ -113,7 +172,7 @@ module MaintenanceTasks
113
172
  #
114
173
  # @return [Boolean] whether the Run is stopping.
115
174
  def stopping?
116
- pausing? || cancelling?
175
+ STOPPING_STATUSES.include?(status.to_sym)
117
176
  end
118
177
 
119
178
  # Returns whether the Run is stopped, which is defined as having a status of
@@ -149,19 +208,78 @@ module MaintenanceTasks
149
208
  ACTIVE_STATUSES.include?(status.to_sym)
150
209
  end
151
210
 
152
- # Returns the estimated time the task will finish based on the the number of
153
- # ticks left and the average time needed to process a tick.
154
- # Returns nil if the Run is completed, or if the tick_count or tick_total is
155
- # zero.
211
+ # Returns the duration left for the Run to finish based on the number of
212
+ # ticks left and the average time needed to process a tick. Returns nil if
213
+ # the Run is completed, or if tick_count or tick_total is zero.
156
214
  #
157
- # @return [Time] the estimated time the Run will finish.
158
- def estimated_completion_time
215
+ # @return [ActiveSupport::Duration] the estimated duration left for the Run
216
+ # to finish.
217
+ def time_to_completion
159
218
  return if completed? || tick_count == 0 || tick_total.to_i == 0
160
219
 
161
220
  processed_per_second = (tick_count.to_f / time_running)
162
221
  ticks_left = (tick_total - tick_count)
163
222
  seconds_to_finished = ticks_left / processed_per_second
164
- Time.now + seconds_to_finished
223
+ seconds_to_finished.seconds
224
+ end
225
+
226
+ # Marks a Run as running.
227
+ #
228
+ # If the run is stopping already, it will not transition to running.
229
+ # Rescues and retries status transition if an ActiveRecord::StaleObjectError
230
+ # is encountered.
231
+ def running
232
+ if locking_enabled?
233
+ begin
234
+ running! unless stopping?
235
+ rescue ActiveRecord::StaleObjectError
236
+ reload_status
237
+ retry
238
+ end
239
+ else
240
+ # Preserve swap-and-replace solution for data races until users
241
+ # run migration to upgrade to optimistic locking solution
242
+ return if stopping?
243
+ updated = self.class.where(id: id).where.not(status: STOPPING_STATUSES)
244
+ .update_all(status: :running, updated_at: Time.now) > 0
245
+ if updated
246
+ self.status = :running
247
+ clear_attribute_changes([:status])
248
+ else
249
+ reload_status
250
+ end
251
+ end
252
+ end
253
+
254
+ # Starts a Run, setting its started_at timestamp and tick_total.
255
+ #
256
+ # @param count [Integer] the total iterations to be performed, as
257
+ # specified by the Task.
258
+ def start(count)
259
+ update!(started_at: Time.now, tick_total: count)
260
+ run_task_callbacks(:start)
261
+ rescue ActiveRecord::StaleObjectError
262
+ reload_status
263
+ retry
264
+ end
265
+
266
+ # Handles transitioning the status on a Run when the job shuts down.
267
+ def job_shutdown
268
+ if cancelling?
269
+ self.status = :cancelled
270
+ self.ended_at = Time.now
271
+ elsif pausing?
272
+ self.status = :paused
273
+ else
274
+ self.status = :interrupted
275
+ end
276
+ end
277
+
278
+ # Handles the completion of a Run, setting a status of succeeded and the
279
+ # ended_at timestamp.
280
+ def complete
281
+ self.status = :succeeded
282
+ self.ended_at = Time.now
165
283
  end
166
284
 
167
285
  # Cancels a Run.
@@ -177,10 +295,26 @@ module MaintenanceTasks
177
295
  # will be updated.
178
296
  def cancel
179
297
  if paused? || stuck?
180
- update!(status: :cancelled, ended_at: Time.now)
298
+ self.status = :cancelled
299
+ self.ended_at = Time.now
300
+ persist_transition
181
301
  else
182
302
  cancelling!
183
303
  end
304
+ rescue ActiveRecord::StaleObjectError
305
+ reload_status
306
+ retry
307
+ end
308
+
309
+ # Marks a Run as pausing.
310
+ #
311
+ # Rescues and retries status transition if an ActiveRecord::StaleObjectError
312
+ # is encountered.
313
+ def pausing!
314
+ super
315
+ rescue ActiveRecord::StaleObjectError
316
+ reload_status
317
+ retry
184
318
  end
185
319
 
186
320
  # Returns whether a Run is stuck, which is defined as having a status of
@@ -188,7 +322,7 @@ module MaintenanceTasks
188
322
  #
189
323
  # @return [Boolean] whether the Run is stuck.
190
324
  def stuck?
191
- cancelling? && updated_at <= 5.minutes.ago
325
+ cancelling? && updated_at <= STUCK_TASK_TIMEOUT.ago
192
326
  end
193
327
 
194
328
  # Performs validation on the presence of a :csv_file attachment.
@@ -197,15 +331,51 @@ module MaintenanceTasks
197
331
  # should not have an attachment to be valid. The appropriate error is added
198
332
  # if the Run does not meet the above criteria.
199
333
  def csv_attachment_presence
200
- if Task.named(task_name) < CsvCollection && !csv_file.attached?
334
+ if Task.named(task_name).has_csv_content? && !csv_file.attached?
201
335
  errors.add(:csv_file, "must be attached to CSV Task.")
202
- elsif !(Task.named(task_name) < CsvCollection) && csv_file.present?
336
+ elsif !Task.named(task_name).has_csv_content? && csv_file.present?
203
337
  errors.add(:csv_file, "should not be attached to non-CSV Task.")
204
338
  end
205
339
  rescue Task::NotFoundError
206
340
  nil
207
341
  end
208
342
 
343
+ # Support iterating over ActiveModel::Errors in Rails 6.0 and Rails 6.1+.
344
+ # To be removed when Rails 6.0 is no longer supported.
345
+ if Rails::VERSION::STRING.match?(/^6.0/)
346
+ # Performs validation on the arguments to use for the Task. If the Task is
347
+ # invalid, the errors are added to the Run.
348
+ def validate_task_arguments
349
+ arguments_match_task_attributes if arguments.present?
350
+ if task.invalid?
351
+ error_messages = task.errors
352
+ .map { |attribute, message| "#{attribute.inspect} #{message}" }
353
+ errors.add(
354
+ :arguments,
355
+ "are invalid: #{error_messages.join("; ")}"
356
+ )
357
+ end
358
+ rescue Task::NotFoundError
359
+ nil
360
+ end
361
+ else
362
+ # Performs validation on the arguments to use for the Task. If the Task is
363
+ # invalid, the errors are added to the Run.
364
+ def validate_task_arguments
365
+ arguments_match_task_attributes if arguments.present?
366
+ if task.invalid?
367
+ error_messages = task.errors
368
+ .map { |error| "#{error.attribute.inspect} #{error.message}" }
369
+ errors.add(
370
+ :arguments,
371
+ "are invalid: #{error_messages.join("; ")}"
372
+ )
373
+ end
374
+ rescue Task::NotFoundError
375
+ nil
376
+ end
377
+ end
378
+
209
379
  # Fetches the attached ActiveStorage CSV file for the run. Checks first
210
380
  # whether the ActiveStorage::Attachment table exists so that we are
211
381
  # compatible with apps that are not using ActiveStorage.
@@ -216,5 +386,41 @@ module MaintenanceTasks
216
386
  return unless ActiveStorage::Attachment.table_exists?
217
387
  super
218
388
  end
389
+
390
+ # Returns a Task instance for this Run. Assigns any attributes to the Task
391
+ # based on the Run's parameters. Note that the Task instance is not supplied
392
+ # with :csv_content yet if it's a CSV Task. This is done in the job, since
393
+ # downloading the CSV file can take some time.
394
+ #
395
+ # @return [Task] a Task instance.
396
+ def task
397
+ @task ||= begin
398
+ task = Task.named(task_name).new
399
+ if task.attribute_names.any? && arguments.present?
400
+ task.assign_attributes(arguments)
401
+ end
402
+ task
403
+ rescue ActiveModel::UnknownAttributeError
404
+ task
405
+ end
406
+ end
407
+
408
+ private
409
+
410
+ def run_task_callbacks(callback)
411
+ task.run_callbacks(callback)
412
+ rescue
413
+ nil
414
+ end
415
+
416
+ def arguments_match_task_attributes
417
+ invalid_argument_keys = arguments.keys - task.attribute_names
418
+ if invalid_argument_keys.any?
419
+ error_message = <<~MSG.squish
420
+ Unknown parameters: #{invalid_argument_keys.map(&:to_sym).join(", ")}
421
+ MSG
422
+ errors.add(:base, error_message)
423
+ end
424
+ end
219
425
  end
220
426
  end
@@ -37,25 +37,35 @@ module MaintenanceTasks
37
37
  # for the Task to iterate over when running, in the form of an attachable
38
38
  # (see https://edgeapi.rubyonrails.org/classes/ActiveStorage/Attached/One.html#method-i-attach).
39
39
  # Value is nil if the Task does not use CSV iteration.
40
+ # @param arguments [Hash] the arguments to persist to the Run and to make
41
+ # accessible to the Task.
40
42
  #
41
43
  # @return [Task] the Task that was run.
42
44
  #
43
45
  # @raise [EnqueuingError] if an error occurs while enqueuing the Run.
44
46
  # @raise [ActiveRecord::RecordInvalid] if validation errors occur while
45
47
  # creating the Run.
46
- def run(name:, csv_file: nil)
47
- run = Run.active.find_by(task_name: name) || Run.new(task_name: name)
48
- run.csv_file.attach(csv_file) if csv_file
49
-
48
+ # @raise [ActiveRecord::ValueTooLong] if the creation of the Run fails due
49
+ # to a value being too long for the column type.
50
+ def run(name:, csv_file: nil, arguments: {}, run_model: Run)
51
+ run = run_model.active.find_by(task_name: name) ||
52
+ run_model.new(task_name: name, arguments: arguments)
53
+ if csv_file
54
+ run.csv_file.attach(csv_file)
55
+ run.csv_file.filename = filename(name)
56
+ end
57
+ job = instantiate_job(run)
58
+ run.job_id = job.job_id
59
+ yield run if block_given?
50
60
  run.enqueued!
51
- enqueue(run)
61
+ enqueue(run, job)
52
62
  Task.named(name)
53
63
  end
54
64
 
55
65
  private
56
66
 
57
- def enqueue(run)
58
- unless MaintenanceTasks.job.constantize.perform_later(run)
67
+ def enqueue(run, job)
68
+ unless job.enqueue
59
69
  raise "The job to perform #{run.task_name} could not be enqueued. "\
60
70
  "Enqueuing has been prevented by a callback."
61
71
  end
@@ -63,5 +73,14 @@ module MaintenanceTasks
63
73
  run.persist_error(error)
64
74
  raise EnqueuingError, run
65
75
  end
76
+
77
+ def filename(task_name)
78
+ formatted_task_name = task_name.underscore.gsub("/", "_")
79
+ "#{Time.now.utc.strftime("%Y%m%dT%H%M%SZ")}_#{formatted_task_name}.csv"
80
+ end
81
+
82
+ def instantiate_job(run)
83
+ MaintenanceTasks.job.constantize.new(run)
84
+ end
66
85
  end
67
86
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module MaintenanceTasks
3
4
  # This class is responsible for handling cursor-based pagination for Run
4
5
  # records.