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.
- checksums.yaml +4 -4
- data/README.md +288 -45
- data/app/controllers/maintenance_tasks/tasks_controller.rb +7 -2
- data/app/helpers/maintenance_tasks/application_helper.rb +1 -0
- data/app/helpers/maintenance_tasks/tasks_helper.rb +19 -14
- data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +44 -23
- data/app/models/maintenance_tasks/application_record.rb +1 -0
- data/app/models/maintenance_tasks/csv_collection_builder.rb +33 -0
- data/app/models/maintenance_tasks/null_collection_builder.rb +31 -0
- data/app/models/maintenance_tasks/progress.rb +8 -3
- data/app/models/maintenance_tasks/run.rb +224 -18
- data/app/models/maintenance_tasks/runner.rb +26 -7
- data/app/models/maintenance_tasks/runs_page.rb +1 -0
- data/app/models/maintenance_tasks/task.rb +225 -0
- data/app/models/maintenance_tasks/task_data.rb +24 -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/_paused.html.erb +2 -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/index.html.erb +2 -2
- data/app/views/maintenance_tasks/tasks/show.html.erb +37 -2
- 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 +7 -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/templates/task.rb.tt +3 -1
- data/lib/maintenance_tasks/cli.rb +11 -4
- data/lib/maintenance_tasks/engine.rb +15 -1
- data/lib/maintenance_tasks.rb +14 -1
- data/lib/patches/active_record_batch_enumerator.rb +23 -0
- metadata +15 -11
- data/app/models/maintenance_tasks/csv_collection.rb +0 -33
- data/app/tasks/maintenance_tasks/task.rb +0 -133
- 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,
|
45
|
-
|
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(
|
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 =
|
75
|
-
if @task.
|
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
|
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
|
-
|
89
|
-
|
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
|
-
|
98
|
-
|
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.
|
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
|
@@ -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 #{
|
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
|
|
@@ -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:
|
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
|
97
|
-
# 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
|
+
#
|
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
|
-
|
104
|
-
|
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
|
-
|
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
|
-
|
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
|
153
|
-
# ticks left and the average time needed to process a tick.
|
154
|
-
#
|
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 [
|
158
|
-
|
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
|
-
|
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
|
-
|
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 <=
|
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)
|
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 !
|
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
|
-
|
47
|
-
|
48
|
-
|
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
|
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
|