maintenance_tasks 1.4.0 → 1.8.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +229 -41
  3. data/app/controllers/maintenance_tasks/tasks_controller.rb +2 -1
  4. data/app/helpers/maintenance_tasks/application_helper.rb +1 -0
  5. data/app/helpers/maintenance_tasks/tasks_helper.rb +19 -0
  6. data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +28 -21
  7. data/app/models/maintenance_tasks/application_record.rb +1 -0
  8. data/app/models/maintenance_tasks/csv_collection_builder.rb +38 -0
  9. data/app/models/maintenance_tasks/no_collection_builder.rb +29 -0
  10. data/app/models/maintenance_tasks/null_collection_builder.rb +38 -0
  11. data/app/models/maintenance_tasks/progress.rb +8 -3
  12. data/app/models/maintenance_tasks/run.rb +157 -13
  13. data/app/models/maintenance_tasks/runner.rb +22 -9
  14. data/app/models/maintenance_tasks/runs_page.rb +1 -0
  15. data/app/models/maintenance_tasks/task.rb +236 -0
  16. data/app/models/maintenance_tasks/task_data.rb +15 -3
  17. data/app/validators/maintenance_tasks/run_status_validator.rb +2 -2
  18. data/app/views/maintenance_tasks/runs/_arguments.html.erb +22 -0
  19. data/app/views/maintenance_tasks/runs/_csv.html.erb +5 -0
  20. data/app/views/maintenance_tasks/runs/_run.html.erb +18 -1
  21. data/app/views/maintenance_tasks/runs/info/_custom.html.erb +0 -0
  22. data/app/views/maintenance_tasks/runs/info/_errored.html.erb +0 -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/show.html.erb +32 -7
  27. data/config/routes.rb +1 -0
  28. data/db/migrate/20201211151756_create_maintenance_tasks_runs.rb +1 -0
  29. data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -0
  30. data/db/migrate/20210517131953_add_arguments_to_maintenance_tasks_runs.rb +1 -0
  31. data/db/migrate/20211210152329_add_lock_version_to_maintenance_tasks_runs.rb +8 -0
  32. data/lib/generators/maintenance_tasks/install_generator.rb +1 -0
  33. data/lib/generators/maintenance_tasks/task_generator.rb +13 -0
  34. data/lib/generators/maintenance_tasks/templates/no_collection_task.rb.tt +13 -0
  35. data/lib/generators/maintenance_tasks/templates/no_collection_task_test.rb.tt +12 -0
  36. data/lib/generators/maintenance_tasks/templates/task.rb.tt +3 -1
  37. data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +4 -0
  38. data/lib/maintenance_tasks/cli.rb +6 -5
  39. data/lib/maintenance_tasks/engine.rb +15 -1
  40. data/lib/maintenance_tasks.rb +12 -1
  41. metadata +15 -7
  42. data/app/models/maintenance_tasks/csv_collection.rb +0 -33
  43. data/app/tasks/maintenance_tasks/task.rb +0 -137
  44. data/app/views/maintenance_tasks/runs/_info.html.erb +0 -16
@@ -0,0 +1,38 @@
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
+
33
+ # Returns that the Task processes a collection.
34
+ def no_collection?
35
+ false
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Strategy for building a Task that has no collection. These Tasks
5
+ # consist of a single iteration.
6
+ #
7
+ # @api private
8
+ class NoCollectionBuilder
9
+ # Specifies that this task does not process a collection.
10
+ def collection(_task)
11
+ :no_collection
12
+ end
13
+
14
+ # The number of rows to be processed. Always returns 1.
15
+ def count(_task)
16
+ 1
17
+ end
18
+
19
+ # Return that the Task does not process CSV content.
20
+ def has_csv_content?
21
+ false
22
+ end
23
+
24
+ # Returns that the Task is collection-less.
25
+ def no_collection?
26
+ true
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base strategy for building a collection-based Task to be performed.
5
+ #
6
+ # @api private
7
+ class NullCollectionBuilder
8
+ # Placeholder method to raise in case a subclass fails to implement the
9
+ # expected instance method.
10
+ #
11
+ # @raise [NotImplementedError] with a message advising subclasses to
12
+ # implement an override for this method.
13
+ def collection(task)
14
+ raise NoMethodError, "#{task.class.name} must implement `collection`."
15
+ end
16
+
17
+ # Total count of iterations to be performed.
18
+ #
19
+ # Tasks override this method to define the total amount of iterations
20
+ # expected at the start of the run. Return +nil+ if the amount is
21
+ # undefined, or counting would be prohibitive for your database.
22
+ #
23
+ # @return [Integer, nil]
24
+ def count(task)
25
+ :no_count
26
+ end
27
+
28
+ # Return that the Task does not process CSV content.
29
+ def has_csv_content?
30
+ false
31
+ end
32
+
33
+ # Returns that the Task processes a collection.
34
+ def no_collection?
35
+ false
36
+ end
37
+ end
38
+ 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
 
@@ -60,9 +66,40 @@ module MaintenanceTasks
60
66
 
61
67
  # Sets the run status to enqueued, making sure the transition is validated
62
68
  # in case it's already enqueued.
69
+ #
70
+ # Rescues and retries status transition if an ActiveRecord::StaleObjectError
71
+ # is encountered.
63
72
  def enqueued!
64
73
  status_will_change!
65
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
66
103
  end
67
104
 
68
105
  # Increments +tick_count+ by +number_of_ticks+ and +time_running+ by
@@ -80,33 +117,53 @@ module MaintenanceTasks
80
117
  time_running: duration,
81
118
  touch: true
82
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
83
125
  end
84
126
 
85
127
  # Marks the run as errored and persists the error data.
86
128
  #
87
129
  # @param error [StandardError] the Error being persisted.
88
130
  def persist_error(error)
131
+ self.started_at ||= Time.now
89
132
  update!(
90
133
  status: :errored,
91
- error_class: error.class.to_s,
92
- error_message: error.message,
93
- backtrace: Rails.backtrace_cleaner.clean(error.backtrace),
134
+ error_class: truncate(:error_class, error.class.name),
135
+ error_message: truncate(:error_message, error.message),
136
+ backtrace: MaintenanceTasks.backtrace_cleaner.clean(error.backtrace),
94
137
  ended_at: Time.now,
95
138
  )
139
+ run_task_callbacks(:error)
140
+ rescue ActiveRecord::StaleObjectError
141
+ reload_status
142
+ retry
96
143
  end
97
144
 
98
- # Refreshes just the status attribute on the Active Record object, and
99
- # 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
+ #
100
148
  # This allows us to get the Run's most up-to-date status without needing
101
149
  # to reload the entire record.
102
150
  #
103
151
  # @return [MaintenanceTasks::Run] the Run record with its updated status.
104
152
  def reload_status
105
- updated_status = Run.uncached do
106
- Run.where(id: id).pluck(:status).first
153
+ columns_to_reload = if locking_enabled?
154
+ [:status, self.class.locking_column]
155
+ else
156
+ [:status]
157
+ end
158
+ updated_status, updated_lock_version = self.class.uncached do
159
+ self.class.where(id: id).pluck(*columns_to_reload).first
107
160
  end
161
+
108
162
  self.status = updated_status
109
- 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)
110
167
  self
111
168
  end
112
169
 
@@ -115,7 +172,7 @@ module MaintenanceTasks
115
172
  #
116
173
  # @return [Boolean] whether the Run is stopping.
117
174
  def stopping?
118
- pausing? || cancelling?
175
+ STOPPING_STATUSES.include?(status.to_sym)
119
176
  end
120
177
 
121
178
  # Returns whether the Run is stopped, which is defined as having a status of
@@ -166,6 +223,65 @@ module MaintenanceTasks
166
223
  seconds_to_finished.seconds
167
224
  end
168
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
283
+ end
284
+
169
285
  # Cancels a Run.
170
286
  #
171
287
  # If the Run is paused, it will transition directly to cancelled, since the
@@ -179,10 +295,26 @@ module MaintenanceTasks
179
295
  # will be updated.
180
296
  def cancel
181
297
  if paused? || stuck?
182
- update!(status: :cancelled, ended_at: Time.now)
298
+ self.status = :cancelled
299
+ self.ended_at = Time.now
300
+ persist_transition
183
301
  else
184
302
  cancelling!
185
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
186
318
  end
187
319
 
188
320
  # Returns whether a Run is stuck, which is defined as having a status of
@@ -190,7 +322,7 @@ module MaintenanceTasks
190
322
  #
191
323
  # @return [Boolean] whether the Run is stuck.
192
324
  def stuck?
193
- cancelling? && updated_at <= 5.minutes.ago
325
+ cancelling? && updated_at <= STUCK_TASK_TIMEOUT.ago
194
326
  end
195
327
 
196
328
  # Performs validation on the presence of a :csv_file attachment.
@@ -199,9 +331,9 @@ module MaintenanceTasks
199
331
  # should not have an attachment to be valid. The appropriate error is added
200
332
  # if the Run does not meet the above criteria.
201
333
  def csv_attachment_presence
202
- if Task.named(task_name) < CsvCollection && !csv_file.attached?
334
+ if Task.named(task_name).has_csv_content? && !csv_file.attached?
203
335
  errors.add(:csv_file, "must be attached to CSV Task.")
204
- elsif !(Task.named(task_name) < CsvCollection) && csv_file.present?
336
+ elsif !Task.named(task_name).has_csv_content? && csv_file.present?
205
337
  errors.add(:csv_file, "should not be attached to non-CSV Task.")
206
338
  end
207
339
  rescue Task::NotFoundError
@@ -275,6 +407,12 @@ module MaintenanceTasks
275
407
 
276
408
  private
277
409
 
410
+ def run_task_callbacks(callback)
411
+ task.run_callbacks(callback)
412
+ rescue
413
+ nil
414
+ end
415
+
278
416
  def arguments_match_task_attributes
279
417
  invalid_argument_keys = arguments.keys - task.attribute_names
280
418
  if invalid_argument_keys.any?
@@ -284,5 +422,11 @@ module MaintenanceTasks
284
422
  errors.add(:base, error_message)
285
423
  end
286
424
  end
425
+
426
+ def truncate(attribute_name, value)
427
+ limit = MaintenanceTasks::Run.column_for_attribute(attribute_name).limit
428
+ return value unless limit
429
+ value&.first(limit)
430
+ end
287
431
  end
288
432
  end
@@ -47,21 +47,25 @@ module MaintenanceTasks
47
47
  # creating the Run.
48
48
  # @raise [ActiveRecord::ValueTooLong] if the creation of the Run fails due
49
49
  # to a value being too long for the column type.
50
- def run(name:, csv_file: nil, arguments: {})
51
- run = Run.active.find_by(task_name: name) ||
52
- Run.new(task_name: name, arguments: arguments)
53
- run.csv_file.attach(csv_file) if csv_file
54
-
55
- run.enqueued!
56
- enqueue(run)
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
57
59
  yield run if block_given?
60
+ run.enqueued!
61
+ enqueue(run, job)
58
62
  Task.named(name)
59
63
  end
60
64
 
61
65
  private
62
66
 
63
- def enqueue(run)
64
- unless MaintenanceTasks.job.constantize.perform_later(run)
67
+ def enqueue(run, job)
68
+ unless job.enqueue
65
69
  raise "The job to perform #{run.task_name} could not be enqueued. "\
66
70
  "Enqueuing has been prevented by a callback."
67
71
  end
@@ -69,5 +73,14 @@ module MaintenanceTasks
69
73
  run.persist_error(error)
70
74
  raise EnqueuingError, run
71
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
72
85
  end
73
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.
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base class that is inherited by the host application's task classes.
5
+ class Task
6
+ extend ActiveSupport::DescendantsTracker
7
+ include ActiveSupport::Callbacks
8
+ include ActiveModel::Attributes
9
+ include ActiveModel::AttributeAssignment
10
+ include ActiveModel::Validations
11
+
12
+ class NotFoundError < NameError; end
13
+
14
+ # The throttle conditions for a given Task. This is provided as an array of
15
+ # hashes, with each hash specifying two keys: throttle_condition and
16
+ # backoff. Note that Tasks inherit conditions from their superclasses.
17
+ #
18
+ # @api private
19
+ class_attribute :throttle_conditions, default: []
20
+
21
+ # @api private
22
+ class_attribute :collection_builder_strategy,
23
+ default: NullCollectionBuilder.new
24
+
25
+ define_callbacks :start, :complete, :error, :cancel, :pause, :interrupt
26
+
27
+ class << self
28
+ # Finds a Task with the given name.
29
+ #
30
+ # @param name [String] the name of the Task to be found.
31
+ #
32
+ # @return [Task] the Task with the given name.
33
+ #
34
+ # @raise [NotFoundError] if a Task with the given name does not exist.
35
+ def named(name)
36
+ task = name.safe_constantize
37
+ raise NotFoundError.new("Task #{name} not found.", name) unless task
38
+ unless task.is_a?(Class) && task < Task
39
+ raise NotFoundError.new("#{name} is not a Task.", name)
40
+ end
41
+ task
42
+ end
43
+
44
+ # Returns a list of concrete classes that inherit from the Task
45
+ # superclass.
46
+ #
47
+ # @return [Array<Class>] the list of classes.
48
+ def available_tasks
49
+ load_constants
50
+ descendants
51
+ end
52
+
53
+ # Make this Task a task that handles CSV.
54
+ #
55
+ # An input to upload a CSV will be added in the form to start a Run. The
56
+ # collection and count method are implemented.
57
+ def csv_collection
58
+ unless defined?(ActiveStorage)
59
+ raise NotImplementedError, "Active Storage needs to be installed\n"\
60
+ "To resolve this issue run: bin/rails active_storage:install"
61
+ end
62
+
63
+ self.collection_builder_strategy =
64
+ MaintenanceTasks::CsvCollectionBuilder.new
65
+ end
66
+
67
+ # Make this a Task that calls #process once, instead of iterating over
68
+ # a collection.
69
+ def no_collection
70
+ self.collection_builder_strategy =
71
+ MaintenanceTasks::NoCollectionBuilder.new
72
+ end
73
+
74
+ delegate :has_csv_content?, :no_collection?,
75
+ to: :collection_builder_strategy
76
+
77
+ # Processes one item.
78
+ #
79
+ # Especially useful for tests.
80
+ #
81
+ # @param args [Object, nil] the item to process
82
+ def process(*args)
83
+ new.process(*args)
84
+ end
85
+
86
+ # Returns the collection for this Task.
87
+ #
88
+ # Especially useful for tests.
89
+ #
90
+ # @return the collection.
91
+ def collection
92
+ new.collection
93
+ end
94
+
95
+ # Returns the count of items for this Task.
96
+ #
97
+ # Especially useful for tests.
98
+ #
99
+ # @return the count of items.
100
+ def count
101
+ new.count
102
+ end
103
+
104
+ # Add a condition under which this Task will be throttled.
105
+ #
106
+ # @param backoff [ActiveSupport::Duration, #call] a custom backoff
107
+ # can be specified. This is the time to wait before retrying the Task,
108
+ # defaulting to 30 seconds. If provided as a Duration, the backoff is
109
+ # wrapped in a proc. Alternatively,an object responding to call can be
110
+ # used. It must return an ActiveSupport::Duration.
111
+ # @yieldreturn [Boolean] where the throttle condition is being met,
112
+ # indicating that the Task should throttle.
113
+ def throttle_on(backoff: 30.seconds, &condition)
114
+ backoff_as_proc = backoff
115
+ backoff_as_proc = -> { backoff } unless backoff.respond_to?(:call)
116
+
117
+ self.throttle_conditions += [
118
+ { throttle_on: condition, backoff: backoff_as_proc },
119
+ ]
120
+ end
121
+
122
+ # Initialize a callback to run after the task starts.
123
+ #
124
+ # @param filter_list apply filters to the callback
125
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
126
+ def after_start(*filter_list, &block)
127
+ set_callback(:start, :after, *filter_list, &block)
128
+ end
129
+
130
+ # Initialize a callback to run after the task completes.
131
+ #
132
+ # @param filter_list apply filters to the callback
133
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
134
+ def after_complete(*filter_list, &block)
135
+ set_callback(:complete, :after, *filter_list, &block)
136
+ end
137
+
138
+ # Initialize a callback to run after the task pauses.
139
+ #
140
+ # @param filter_list apply filters to the callback
141
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
142
+ def after_pause(*filter_list, &block)
143
+ set_callback(:pause, :after, *filter_list, &block)
144
+ end
145
+
146
+ # Initialize a callback to run after the task is interrupted.
147
+ #
148
+ # @param filter_list apply filters to the callback
149
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
150
+ def after_interrupt(*filter_list, &block)
151
+ set_callback(:interrupt, :after, *filter_list, &block)
152
+ end
153
+
154
+ # Initialize a callback to run after the task is cancelled.
155
+ #
156
+ # @param filter_list apply filters to the callback
157
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
158
+ def after_cancel(*filter_list, &block)
159
+ set_callback(:cancel, :after, *filter_list, &block)
160
+ end
161
+
162
+ # Initialize a callback to run after the task produces an error.
163
+ #
164
+ # @param filter_list apply filters to the callback
165
+ # (see https://api.rubyonrails.org/classes/ActiveSupport/Callbacks/ClassMethods.html#method-i-set_callback)
166
+ def after_error(*filter_list, &block)
167
+ set_callback(:error, :after, *filter_list, &block)
168
+ end
169
+
170
+ private
171
+
172
+ def load_constants
173
+ namespace = MaintenanceTasks.tasks_module.safe_constantize
174
+ return unless namespace
175
+ namespace.constants.map { |constant| namespace.const_get(constant) }
176
+ end
177
+ end
178
+
179
+ # The contents of a CSV file to be processed by a Task.
180
+ #
181
+ # @return [String] the content of the CSV file to process.
182
+ def csv_content
183
+ raise NoMethodError unless has_csv_content?
184
+
185
+ @csv_content
186
+ end
187
+
188
+ # Set the contents of a CSV file to be processed by a Task.
189
+ #
190
+ # @param csv_content [String] the content of the CSV file to process.
191
+ def csv_content=(csv_content)
192
+ raise NoMethodError unless has_csv_content?
193
+
194
+ @csv_content = csv_content
195
+ end
196
+
197
+ # Returns whether the Task handles CSV.
198
+ #
199
+ # @return [Boolean] whether the Task handles CSV.
200
+ def has_csv_content?
201
+ self.class.has_csv_content?
202
+ end
203
+
204
+ # Returns whether the Task is collection-less.
205
+ #
206
+ # @return [Boolean] whether the Task is collection-less.
207
+ def no_collection?
208
+ self.class.no_collection?
209
+ end
210
+
211
+ # The collection to be processed, delegated to the strategy.
212
+ #
213
+ # @return the collection.
214
+ def collection
215
+ self.class.collection_builder_strategy.collection(self)
216
+ end
217
+
218
+ # Placeholder method to raise in case a subclass fails to implement the
219
+ # expected instance method.
220
+ #
221
+ # @param _item [Object] the current item from the enumerator being iterated.
222
+ #
223
+ # @raise [NotImplementedError] with a message advising subclasses to
224
+ # implement an override for this method.
225
+ def process(_item)
226
+ raise NoMethodError, "#{self.class.name} must implement `process`."
227
+ end
228
+
229
+ # Total count of iterations to be performed, delegated to the strategy.
230
+ #
231
+ # @return [Integer, nil]
232
+ def count
233
+ self.class.collection_builder_strategy.count(self)
234
+ end
235
+ end
236
+ end