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.
- checksums.yaml +4 -4
- data/README.md +229 -41
- data/app/controllers/maintenance_tasks/tasks_controller.rb +2 -1
- data/app/helpers/maintenance_tasks/application_helper.rb +1 -0
- data/app/helpers/maintenance_tasks/tasks_helper.rb +19 -0
- data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +28 -21
- data/app/models/maintenance_tasks/application_record.rb +1 -0
- data/app/models/maintenance_tasks/csv_collection_builder.rb +38 -0
- data/app/models/maintenance_tasks/no_collection_builder.rb +29 -0
- data/app/models/maintenance_tasks/null_collection_builder.rb +38 -0
- data/app/models/maintenance_tasks/progress.rb +8 -3
- data/app/models/maintenance_tasks/run.rb +157 -13
- data/app/models/maintenance_tasks/runner.rb +22 -9
- data/app/models/maintenance_tasks/runs_page.rb +1 -0
- data/app/models/maintenance_tasks/task.rb +236 -0
- data/app/models/maintenance_tasks/task_data.rb +15 -3
- data/app/validators/maintenance_tasks/run_status_validator.rb +2 -2
- data/app/views/maintenance_tasks/runs/_arguments.html.erb +22 -0
- data/app/views/maintenance_tasks/runs/_csv.html.erb +5 -0
- data/app/views/maintenance_tasks/runs/_run.html.erb +18 -1
- data/app/views/maintenance_tasks/runs/info/_custom.html.erb +0 -0
- data/app/views/maintenance_tasks/runs/info/_errored.html.erb +0 -2
- data/app/views/maintenance_tasks/runs/info/_running.html.erb +3 -5
- data/app/views/maintenance_tasks/tasks/_custom.html.erb +0 -0
- data/app/views/maintenance_tasks/tasks/_task.html.erb +19 -1
- data/app/views/maintenance_tasks/tasks/show.html.erb +32 -7
- data/config/routes.rb +1 -0
- data/db/migrate/20201211151756_create_maintenance_tasks_runs.rb +1 -0
- data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -0
- data/db/migrate/20210517131953_add_arguments_to_maintenance_tasks_runs.rb +1 -0
- data/db/migrate/20211210152329_add_lock_version_to_maintenance_tasks_runs.rb +8 -0
- data/lib/generators/maintenance_tasks/install_generator.rb +1 -0
- data/lib/generators/maintenance_tasks/task_generator.rb +13 -0
- data/lib/generators/maintenance_tasks/templates/no_collection_task.rb.tt +13 -0
- data/lib/generators/maintenance_tasks/templates/no_collection_task_test.rb.tt +12 -0
- data/lib/generators/maintenance_tasks/templates/task.rb.tt +3 -1
- data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +4 -0
- data/lib/maintenance_tasks/cli.rb +6 -5
- data/lib/maintenance_tasks/engine.rb +15 -1
- data/lib/maintenance_tasks.rb +12 -1
- metadata +15 -7
- data/app/models/maintenance_tasks/csv_collection.rb +0 -33
- data/app/tasks/maintenance_tasks/task.rb +0 -137
- 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 #{
|
55
|
+
"Processed #{number_to_delimited(count)} "\
|
56
|
+
"#{"item".pluralize(count)}."
|
55
57
|
elsif over_total?
|
56
|
-
"Processed #{
|
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
|
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.
|
92
|
-
error_message: error.message,
|
93
|
-
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
|
99
|
-
# ensures ActiveModel::Dirty
|
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
|
-
|
106
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
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 <=
|
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)
|
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 !
|
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 =
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
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
|
@@ -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
|