maintenance_tasks 2.13.0 → 2.14.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 +2 -2
- data/app/controllers/maintenance_tasks/application_controller.rb +1 -1
- data/app/models/concerns/maintenance_tasks/run_concern.rb +532 -0
- data/app/models/maintenance_tasks/run.rb +1 -513
- data/app/views/layouts/maintenance_tasks/_navbar.html.erb +1 -1
- data/app/views/layouts/maintenance_tasks/application.html.erb +43 -12
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7ceffb83ecb318ecf0edd66c78f55d4dd51c6528d4faf72d6e7ca66efa83db02
|
|
4
|
+
data.tar.gz: 360bc77f347ac909ed96a30495f965cc908cc5fc7956f1edfa8c3b2aba385607
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b46d6c5afcb297a2be58c9e218cad173b4abf65d50784574ac5fb4afdbaeaf83fc9add501e0817c85190b6b0e4299988b9a3ca87d28a83147e912e10f8d98cfd
|
|
7
|
+
data.tar.gz: 5b4ecaeff775b36f909dfe80d50df4834d642ba1fbf09d9668d11d47d011a2bb1a8302b612a07d8bec9f0b4d14ffba2d33f53f6e46b9999b6b74df4c41f9571b
|
data/README.md
CHANGED
|
@@ -242,8 +242,8 @@ seconds to process. Consider skipping the count (defining a `count` that returns
|
|
|
242
242
|
`nil`) or use an approximation, eg: count the number of new lines:
|
|
243
243
|
|
|
244
244
|
```ruby
|
|
245
|
-
def count
|
|
246
|
-
|
|
245
|
+
def count
|
|
246
|
+
csv_content.count("\n") - 1
|
|
247
247
|
end
|
|
248
248
|
```
|
|
249
249
|
|
|
@@ -11,7 +11,7 @@ module MaintenanceTasks
|
|
|
11
11
|
policy.style_src_elem(
|
|
12
12
|
BULMA_CDN,
|
|
13
13
|
# <style> tag in app/views/layouts/maintenance_tasks/application.html.erb
|
|
14
|
-
"'sha256-
|
|
14
|
+
"'sha256-b9tTK1UaF0U8792/A1vIUkeZwjPgECIOeKJdhYED06A='",
|
|
15
15
|
)
|
|
16
16
|
capybara_lockstep_scripts = [
|
|
17
17
|
"'sha256-1AoN3ZtJC5OvqkMgrYvhZjp4kI8QjJjO7TAyKYiDw+U='",
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MaintenanceTasks
|
|
4
|
+
# Concern that holds the behavior of a maintenance task run. It is
|
|
5
|
+
# included in {Run}.
|
|
6
|
+
#
|
|
7
|
+
# @api private
|
|
8
|
+
module RunConcern
|
|
9
|
+
extend ActiveSupport::Concern
|
|
10
|
+
|
|
11
|
+
included do
|
|
12
|
+
# Various statuses a run can be in.
|
|
13
|
+
STATUSES = [
|
|
14
|
+
:enqueued, # The task has been enqueued by the user.
|
|
15
|
+
:running, # The task is being performed by a job worker.
|
|
16
|
+
:succeeded, # The task finished without error.
|
|
17
|
+
:cancelling, # The task has been told to cancel but is finishing work.
|
|
18
|
+
:cancelled, # The user explicitly halted the task's execution.
|
|
19
|
+
:interrupted, # The task was interrupted by the job infrastructure.
|
|
20
|
+
:pausing, # The task has been told to pause but is finishing work.
|
|
21
|
+
:paused, # The task was paused in the middle of the run by the user.
|
|
22
|
+
:errored, # The task code produced an unhandled exception.
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
ACTIVE_STATUSES = [
|
|
26
|
+
:enqueued,
|
|
27
|
+
:running,
|
|
28
|
+
:paused,
|
|
29
|
+
:pausing,
|
|
30
|
+
:cancelling,
|
|
31
|
+
:interrupted,
|
|
32
|
+
]
|
|
33
|
+
STOPPING_STATUSES = [
|
|
34
|
+
:pausing,
|
|
35
|
+
:cancelling,
|
|
36
|
+
:cancelled,
|
|
37
|
+
]
|
|
38
|
+
COMPLETED_STATUSES = [:succeeded, :errored, :cancelled]
|
|
39
|
+
|
|
40
|
+
enum :status, STATUSES.to_h { |status| [status, status.to_s] }
|
|
41
|
+
|
|
42
|
+
after_save :instrument_status_change
|
|
43
|
+
|
|
44
|
+
validate :task_name_belongs_to_a_valid_task, on: :create
|
|
45
|
+
validate :csv_attachment_presence, on: :create
|
|
46
|
+
validate :csv_content_type, on: :create
|
|
47
|
+
validate :validate_task_arguments, on: :create
|
|
48
|
+
|
|
49
|
+
attr_readonly :task_name
|
|
50
|
+
|
|
51
|
+
serialize :backtrace, coder: YAML
|
|
52
|
+
serialize :arguments, coder: JSON
|
|
53
|
+
serialize :metadata, coder: JSON
|
|
54
|
+
|
|
55
|
+
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
|
56
|
+
scope :completed, -> { where(status: COMPLETED_STATUSES) }
|
|
57
|
+
|
|
58
|
+
# Ensure ActiveStorage is in use before preloading the attachments
|
|
59
|
+
scope :with_attached_csv, -> do
|
|
60
|
+
return unless defined?(ActiveStorage)
|
|
61
|
+
|
|
62
|
+
with_attached_csv_file if ActiveStorage::Attachment.table_exists?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
validates_with RunStatusValidator, on: :update
|
|
66
|
+
|
|
67
|
+
if MaintenanceTasks.active_storage_service.present?
|
|
68
|
+
has_one_attached :csv_file,
|
|
69
|
+
service: MaintenanceTasks.active_storage_service
|
|
70
|
+
elsif respond_to?(:has_one_attached)
|
|
71
|
+
has_one_attached :csv_file
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Sets the run status to enqueued, making sure the transition is validated
|
|
75
|
+
# in case it's already enqueued.
|
|
76
|
+
#
|
|
77
|
+
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
78
|
+
# is encountered.
|
|
79
|
+
def enqueued!
|
|
80
|
+
with_stale_object_retry do
|
|
81
|
+
status_will_change!
|
|
82
|
+
super
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
CALLBACKS_TRANSITION = {
|
|
88
|
+
cancelled: :cancel,
|
|
89
|
+
interrupted: :interrupt,
|
|
90
|
+
paused: :pause,
|
|
91
|
+
succeeded: :complete,
|
|
92
|
+
}.transform_keys(&:to_s)
|
|
93
|
+
|
|
94
|
+
DELAYS_PER_ATTEMPT = [0.1, 0.2, 0.4, 0.8, 1.6]
|
|
95
|
+
MAX_RETRIES = DELAYS_PER_ATTEMPT.size
|
|
96
|
+
|
|
97
|
+
private_constant :CALLBACKS_TRANSITION, :DELAYS_PER_ATTEMPT, :MAX_RETRIES
|
|
98
|
+
|
|
99
|
+
# Saves the run, persisting the transition of its status, and all other
|
|
100
|
+
# changes to the object.
|
|
101
|
+
def persist_transition
|
|
102
|
+
retry_count = 0
|
|
103
|
+
begin
|
|
104
|
+
save!
|
|
105
|
+
rescue ActiveRecord::StaleObjectError
|
|
106
|
+
if retry_count < MAX_RETRIES
|
|
107
|
+
sleep(DELAYS_PER_ATTEMPT[retry_count])
|
|
108
|
+
retry_count += 1
|
|
109
|
+
|
|
110
|
+
success = succeeded?
|
|
111
|
+
reload_status
|
|
112
|
+
if success
|
|
113
|
+
self.status = :succeeded
|
|
114
|
+
else
|
|
115
|
+
job_shutdown
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
retry
|
|
119
|
+
else
|
|
120
|
+
raise
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
callback = CALLBACKS_TRANSITION[status]
|
|
125
|
+
run_task_callbacks(callback) if callback
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Increments +tick_count+ by +number_of_ticks+ and +time_running+ by
|
|
129
|
+
# +duration+, both directly in the DB.
|
|
130
|
+
# The attribute values are not set in the current instance, you need
|
|
131
|
+
# to reload the record.
|
|
132
|
+
#
|
|
133
|
+
# @param number_of_ticks [Integer] number of ticks to add to tick_count.
|
|
134
|
+
# @param duration [Float] the time in seconds that elapsed since the last
|
|
135
|
+
# increment of ticks.
|
|
136
|
+
def persist_progress(number_of_ticks, duration)
|
|
137
|
+
self.class.update_counters(
|
|
138
|
+
id,
|
|
139
|
+
tick_count: number_of_ticks,
|
|
140
|
+
time_running: duration,
|
|
141
|
+
touch: true,
|
|
142
|
+
)
|
|
143
|
+
if locking_enabled?
|
|
144
|
+
locking_column = self.class.locking_column
|
|
145
|
+
self[locking_column] += 1
|
|
146
|
+
clear_attribute_change(locking_column)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Marks the run as errored and persists the error data.
|
|
151
|
+
#
|
|
152
|
+
# @param error [StandardError] the Error being persisted.
|
|
153
|
+
def persist_error(error)
|
|
154
|
+
with_stale_object_retry do
|
|
155
|
+
self.started_at ||= Time.now
|
|
156
|
+
update!(
|
|
157
|
+
status: :errored,
|
|
158
|
+
error_class: truncate(:error_class, error.class.name),
|
|
159
|
+
error_message: truncate(:error_message, error.message),
|
|
160
|
+
backtrace: MaintenanceTasks.backtrace_cleaner.clean(error.backtrace),
|
|
161
|
+
ended_at: Time.now,
|
|
162
|
+
)
|
|
163
|
+
end
|
|
164
|
+
run_error_callback
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Refreshes the status and lock version attributes on the Active Record
|
|
168
|
+
# object, and ensures ActiveModel::Dirty doesn't mark the object as changed.
|
|
169
|
+
#
|
|
170
|
+
# This allows us to get the Run's most up-to-date status without needing
|
|
171
|
+
# to reload the entire record.
|
|
172
|
+
#
|
|
173
|
+
# @return [MaintenanceTasks::Run] the Run record with its updated status.
|
|
174
|
+
def reload_status
|
|
175
|
+
columns_to_reload = if locking_enabled?
|
|
176
|
+
[:status, self.class.locking_column]
|
|
177
|
+
else
|
|
178
|
+
[:status]
|
|
179
|
+
end
|
|
180
|
+
updated_status, updated_lock_version = self.class.uncached do
|
|
181
|
+
self.class.where(id: id).pluck(*columns_to_reload).first
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
self.status = updated_status
|
|
185
|
+
if updated_lock_version
|
|
186
|
+
self[self.class.locking_column] = updated_lock_version
|
|
187
|
+
end
|
|
188
|
+
clear_attribute_changes(columns_to_reload)
|
|
189
|
+
self
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Returns whether the Run is stopping, which is defined as having a status
|
|
193
|
+
# of pausing or cancelling. The status of cancelled is also considered
|
|
194
|
+
# stopping since a Run can be cancelled while its job still exists in the
|
|
195
|
+
# queue, and we want to handle it the same way as a cancelling run.
|
|
196
|
+
#
|
|
197
|
+
# @return [Boolean] whether the Run is stopping.
|
|
198
|
+
def stopping?
|
|
199
|
+
STOPPING_STATUSES.include?(status.to_sym)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Returns whether the Run is stopped, which is defined as having a status of
|
|
203
|
+
# paused, succeeded, cancelled, or errored.
|
|
204
|
+
#
|
|
205
|
+
# @return [Boolean] whether the Run is stopped.
|
|
206
|
+
def stopped?
|
|
207
|
+
completed? || paused?
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Returns whether the Run has been started, which is indicated by the
|
|
211
|
+
# started_at timestamp being present.
|
|
212
|
+
#
|
|
213
|
+
# @return [Boolean] whether the Run was started.
|
|
214
|
+
def started?
|
|
215
|
+
started_at.present?
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Returns whether the Run is completed, which is defined as
|
|
219
|
+
# having a status of succeeded, cancelled, or errored.
|
|
220
|
+
#
|
|
221
|
+
# @return [Boolean] whether the Run is completed.
|
|
222
|
+
def completed?
|
|
223
|
+
COMPLETED_STATUSES.include?(status.to_sym)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Returns whether the Run is active, which is defined as
|
|
227
|
+
# having a status of enqueued, running, pausing, cancelling,
|
|
228
|
+
# paused or interrupted.
|
|
229
|
+
#
|
|
230
|
+
# @return [Boolean] whether the Run is active.
|
|
231
|
+
def active?
|
|
232
|
+
ACTIVE_STATUSES.include?(status.to_sym)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Returns the duration left for the Run to finish based on the number of
|
|
236
|
+
# ticks left and the average time needed to process a tick. Returns nil if
|
|
237
|
+
# the Run is completed, or if tick_count or tick_total is zero.
|
|
238
|
+
#
|
|
239
|
+
# @return [ActiveSupport::Duration] the estimated duration left for the Run
|
|
240
|
+
# to finish.
|
|
241
|
+
def time_to_completion
|
|
242
|
+
return if completed? || tick_count == 0 || tick_total.to_i == 0
|
|
243
|
+
|
|
244
|
+
processed_per_second = (tick_count.to_f / time_running)
|
|
245
|
+
ticks_left = (tick_total - tick_count)
|
|
246
|
+
seconds_to_finished = ticks_left / processed_per_second
|
|
247
|
+
seconds_to_finished.seconds
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Marks a Run as running.
|
|
251
|
+
#
|
|
252
|
+
# If the run is stopping already, it will not transition to running.
|
|
253
|
+
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
254
|
+
# is encountered.
|
|
255
|
+
def running
|
|
256
|
+
if locking_enabled?
|
|
257
|
+
with_stale_object_retry do
|
|
258
|
+
running! unless stopping?
|
|
259
|
+
end
|
|
260
|
+
else
|
|
261
|
+
# Preserve swap-and-replace solution for data races until users
|
|
262
|
+
# run migration to upgrade to optimistic locking solution
|
|
263
|
+
return if stopping?
|
|
264
|
+
|
|
265
|
+
updated = self.class.where(id: id).where.not(status: STOPPING_STATUSES)
|
|
266
|
+
.update_all(status: :running, updated_at: Time.now) > 0
|
|
267
|
+
if updated
|
|
268
|
+
self.status = :running
|
|
269
|
+
clear_attribute_changes([:status])
|
|
270
|
+
else
|
|
271
|
+
reload_status
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Starts a Run, setting its started_at timestamp and tick_total.
|
|
277
|
+
#
|
|
278
|
+
# @param count [Integer] the total iterations to be performed, as
|
|
279
|
+
# specified by the Task.
|
|
280
|
+
def start(count)
|
|
281
|
+
with_stale_object_retry do
|
|
282
|
+
update!(started_at: Time.now, tick_total: count)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
task.run_callbacks(:start)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Handles transitioning the status on a Run when the job shuts down.
|
|
289
|
+
def job_shutdown
|
|
290
|
+
if cancelling?
|
|
291
|
+
self.status = :cancelled
|
|
292
|
+
self.ended_at = Time.now
|
|
293
|
+
elsif pausing?
|
|
294
|
+
self.status = :paused
|
|
295
|
+
elsif cancelled?
|
|
296
|
+
else
|
|
297
|
+
self.status = :interrupted
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Handles the completion of a Run, setting a status of succeeded and the
|
|
302
|
+
# ended_at timestamp.
|
|
303
|
+
def complete
|
|
304
|
+
self.status = :succeeded
|
|
305
|
+
self.ended_at = Time.now
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Cancels a Run.
|
|
309
|
+
#
|
|
310
|
+
# If the Run is paused, it will transition directly to cancelled, since the
|
|
311
|
+
# Task is not being performed. In this case, the ended_at timestamp
|
|
312
|
+
# will be updated.
|
|
313
|
+
#
|
|
314
|
+
# If the Run is not paused, the Run will transition to cancelling.
|
|
315
|
+
#
|
|
316
|
+
# If the Run is already cancelling, and has last been updated more than 5
|
|
317
|
+
# minutes ago, it will transition to cancelled, and the ended_at timestamp
|
|
318
|
+
# will be updated.
|
|
319
|
+
def cancel
|
|
320
|
+
with_stale_object_retry do
|
|
321
|
+
if paused? || stuck?
|
|
322
|
+
self.status = :cancelled
|
|
323
|
+
self.ended_at = Time.now
|
|
324
|
+
persist_transition
|
|
325
|
+
else
|
|
326
|
+
cancelling!
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Marks a Run as pausing.
|
|
332
|
+
#
|
|
333
|
+
# If the Run has been stuck on pausing for more than 5 minutes, it forces
|
|
334
|
+
# the transition to paused. The ended_at timestamp will be updated.
|
|
335
|
+
#
|
|
336
|
+
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
337
|
+
# is encountered.
|
|
338
|
+
def pause
|
|
339
|
+
with_stale_object_retry do
|
|
340
|
+
if stuck?
|
|
341
|
+
self.status = :paused
|
|
342
|
+
persist_transition
|
|
343
|
+
else
|
|
344
|
+
pausing!
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Returns whether a Run is stuck, which is defined as having a status of
|
|
350
|
+
# cancelling or pausing, and not having been updated in the last 5 minutes.
|
|
351
|
+
#
|
|
352
|
+
# @return [Boolean] whether the Run is stuck.
|
|
353
|
+
def stuck?
|
|
354
|
+
(cancelling? || pausing?) && updated_at <= MaintenanceTasks.stuck_task_duration.ago
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Performs validation on the task_name attribute.
|
|
358
|
+
# A Run must be associated with a valid Task to be valid.
|
|
359
|
+
# In order to confirm that, the Task is looked up by name.
|
|
360
|
+
def task_name_belongs_to_a_valid_task
|
|
361
|
+
Task.named(task_name)
|
|
362
|
+
rescue Task::NotFoundError
|
|
363
|
+
errors.add(:task_name, "must be the name of an existing Task.")
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Performs validation on the presence of a :csv_file attachment.
|
|
367
|
+
# A Run for a Task that uses CsvCollection must have an attached :csv_file
|
|
368
|
+
# to be valid. Conversely, a Run for a Task that doesn't use CsvCollection
|
|
369
|
+
# should not have an attachment to be valid. The appropriate error is added
|
|
370
|
+
# if the Run does not meet the above criteria.
|
|
371
|
+
def csv_attachment_presence
|
|
372
|
+
if Task.named(task_name).has_csv_content? && !csv_file.attached?
|
|
373
|
+
errors.add(:csv_file, "must be attached to CSV Task.")
|
|
374
|
+
elsif !Task.named(task_name).has_csv_content? && csv_file.present?
|
|
375
|
+
errors.add(:csv_file, "should not be attached to non-CSV Task.")
|
|
376
|
+
end
|
|
377
|
+
rescue Task::NotFoundError
|
|
378
|
+
nil
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Performs validation on the content type of the :csv_file attachment.
|
|
382
|
+
# A Run for a Task that uses CsvCollection must have a present :csv_file
|
|
383
|
+
# and a content type of "text/csv" to be valid. The appropriate error is
|
|
384
|
+
# added if the Run does not meet the above criteria.
|
|
385
|
+
def csv_content_type
|
|
386
|
+
if csv_file.present? && csv_file.content_type != "text/csv"
|
|
387
|
+
errors.add(:csv_file, "must be a CSV")
|
|
388
|
+
end
|
|
389
|
+
rescue Task::NotFoundError
|
|
390
|
+
nil
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Performs validation on the arguments to use for the Task. If the Task is
|
|
394
|
+
# invalid, the errors are added to the Run.
|
|
395
|
+
def validate_task_arguments
|
|
396
|
+
arguments_match_task_attributes if arguments.present?
|
|
397
|
+
if task.invalid?
|
|
398
|
+
error_messages = task.errors
|
|
399
|
+
.map { |error| "#{error.attribute.inspect} #{error.message}" }
|
|
400
|
+
errors.add(
|
|
401
|
+
:arguments,
|
|
402
|
+
"are invalid: #{error_messages.join("; ")}",
|
|
403
|
+
)
|
|
404
|
+
end
|
|
405
|
+
rescue Task::NotFoundError
|
|
406
|
+
nil
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Fetches the attached ActiveStorage CSV file for the run. Checks first
|
|
410
|
+
# whether the ActiveStorage::Attachment table exists so that we are
|
|
411
|
+
# compatible with apps that are not using ActiveStorage.
|
|
412
|
+
#
|
|
413
|
+
# @return [ActiveStorage::Attached::One] the attached CSV file
|
|
414
|
+
def csv_file
|
|
415
|
+
return unless defined?(ActiveStorage)
|
|
416
|
+
return unless ActiveStorage::Attachment.table_exists?
|
|
417
|
+
|
|
418
|
+
super
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Returns a Task instance for this Run. Assigns any attributes to the Task
|
|
422
|
+
# based on the Run's parameters. Note that the Task instance is not supplied
|
|
423
|
+
# with :csv_content yet if it's a CSV Task. This is done in the job, since
|
|
424
|
+
# downloading the CSV file can take some time.
|
|
425
|
+
#
|
|
426
|
+
# @return [Task] a Task instance.
|
|
427
|
+
def task
|
|
428
|
+
@task ||= begin
|
|
429
|
+
task = Task.named(task_name).new
|
|
430
|
+
if task.attribute_names.any? && arguments.present?
|
|
431
|
+
task.assign_attributes(arguments)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
task.metadata = metadata
|
|
435
|
+
task
|
|
436
|
+
rescue ActiveModel::UnknownAttributeError
|
|
437
|
+
task
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Returns all the run arguments with sensitive information masked.
|
|
442
|
+
#
|
|
443
|
+
# @return [Hash] The masked arguments.
|
|
444
|
+
def masked_arguments
|
|
445
|
+
return unless arguments.present?
|
|
446
|
+
|
|
447
|
+
argument_filter.filter(arguments)
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
private
|
|
451
|
+
|
|
452
|
+
def instrument_status_change
|
|
453
|
+
return unless status_previously_changed? || id_previously_changed?
|
|
454
|
+
return if running? || pausing? || cancelling? || interrupted?
|
|
455
|
+
|
|
456
|
+
attr = {
|
|
457
|
+
run_id: id,
|
|
458
|
+
job_id: job_id,
|
|
459
|
+
task_name: task_name,
|
|
460
|
+
arguments: arguments,
|
|
461
|
+
metadata: metadata,
|
|
462
|
+
time_running: time_running,
|
|
463
|
+
started_at: started_at,
|
|
464
|
+
ended_at: ended_at,
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
attr[:error] = {
|
|
468
|
+
message: error_message,
|
|
469
|
+
class: error_class,
|
|
470
|
+
backtrace: backtrace,
|
|
471
|
+
} if errored?
|
|
472
|
+
|
|
473
|
+
ActiveSupport::Notifications.instrument("#{status}.maintenance_tasks", attr)
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def run_task_callbacks(callback)
|
|
477
|
+
task.run_callbacks(callback)
|
|
478
|
+
rescue Task::NotFoundError
|
|
479
|
+
nil
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def run_error_callback
|
|
483
|
+
task.run_callbacks(:error)
|
|
484
|
+
rescue
|
|
485
|
+
nil
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def arguments_match_task_attributes
|
|
489
|
+
invalid_argument_keys = arguments.keys - task.attribute_names
|
|
490
|
+
if invalid_argument_keys.any?
|
|
491
|
+
error_message = <<~MSG.squish
|
|
492
|
+
Unknown parameters: #{invalid_argument_keys.map(&:to_sym).join(", ")}
|
|
493
|
+
MSG
|
|
494
|
+
errors.add(:base, error_message)
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def truncate(attribute_name, value)
|
|
499
|
+
limit = self.class.column_for_attribute(attribute_name).limit
|
|
500
|
+
return value unless limit
|
|
501
|
+
|
|
502
|
+
value&.first(limit)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def argument_filter
|
|
506
|
+
@argument_filter ||= ActiveSupport::ParameterFilter.new(
|
|
507
|
+
Rails.application.config.filter_parameters + task.masked_arguments,
|
|
508
|
+
)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def with_stale_object_retry(retry_count = 0)
|
|
512
|
+
yield
|
|
513
|
+
rescue ActiveRecord::StaleObjectError
|
|
514
|
+
if retry_count < MAX_RETRIES
|
|
515
|
+
sleep(stale_object_retry_delay(retry_count))
|
|
516
|
+
retry_count += 1
|
|
517
|
+
reload_status
|
|
518
|
+
|
|
519
|
+
retry
|
|
520
|
+
else
|
|
521
|
+
raise
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def stale_object_retry_delay(retry_count)
|
|
526
|
+
delay = DELAYS_PER_ATTEMPT[retry_count]
|
|
527
|
+
# Add jitter (±25% randomization) to prevent thundering herd
|
|
528
|
+
jitter = delay * 0.25
|
|
529
|
+
delay + (rand * 2 - 1) * jitter
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
end
|
|
@@ -5,518 +5,6 @@ module MaintenanceTasks
|
|
|
5
5
|
#
|
|
6
6
|
# @api private
|
|
7
7
|
class Run < ApplicationRecord
|
|
8
|
-
|
|
9
|
-
STATUSES = [
|
|
10
|
-
:enqueued, # The task has been enqueued by the user.
|
|
11
|
-
:running, # The task is being performed by a job worker.
|
|
12
|
-
:succeeded, # The task finished without error.
|
|
13
|
-
:cancelling, # The task has been told to cancel but is finishing work.
|
|
14
|
-
:cancelled, # The user explicitly halted the task's execution.
|
|
15
|
-
:interrupted, # The task was interrupted by the job infrastructure.
|
|
16
|
-
:pausing, # The task has been told to pause but is finishing work.
|
|
17
|
-
:paused, # The task was paused in the middle of the run by the user.
|
|
18
|
-
:errored, # The task code produced an unhandled exception.
|
|
19
|
-
]
|
|
20
|
-
|
|
21
|
-
ACTIVE_STATUSES = [
|
|
22
|
-
:enqueued,
|
|
23
|
-
:running,
|
|
24
|
-
:paused,
|
|
25
|
-
:pausing,
|
|
26
|
-
:cancelling,
|
|
27
|
-
:interrupted,
|
|
28
|
-
]
|
|
29
|
-
STOPPING_STATUSES = [
|
|
30
|
-
:pausing,
|
|
31
|
-
:cancelling,
|
|
32
|
-
:cancelled,
|
|
33
|
-
]
|
|
34
|
-
COMPLETED_STATUSES = [:succeeded, :errored, :cancelled]
|
|
35
|
-
|
|
36
|
-
enum :status, STATUSES.to_h { |status| [status, status.to_s] }
|
|
37
|
-
|
|
38
|
-
after_save :instrument_status_change
|
|
39
|
-
|
|
40
|
-
validate :task_name_belongs_to_a_valid_task, on: :create
|
|
41
|
-
validate :csv_attachment_presence, on: :create
|
|
42
|
-
validate :csv_content_type, on: :create
|
|
43
|
-
validate :validate_task_arguments, on: :create
|
|
44
|
-
|
|
45
|
-
attr_readonly :task_name
|
|
46
|
-
|
|
47
|
-
serialize :backtrace, coder: YAML
|
|
48
|
-
serialize :arguments, coder: JSON
|
|
49
|
-
serialize :metadata, coder: JSON
|
|
50
|
-
|
|
51
|
-
scope :active, -> { where(status: ACTIVE_STATUSES) }
|
|
52
|
-
scope :completed, -> { where(status: COMPLETED_STATUSES) }
|
|
53
|
-
|
|
54
|
-
# Ensure ActiveStorage is in use before preloading the attachments
|
|
55
|
-
scope :with_attached_csv, -> do
|
|
56
|
-
return unless defined?(ActiveStorage)
|
|
57
|
-
|
|
58
|
-
with_attached_csv_file if ActiveStorage::Attachment.table_exists?
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
validates_with RunStatusValidator, on: :update
|
|
62
|
-
|
|
63
|
-
if MaintenanceTasks.active_storage_service.present?
|
|
64
|
-
has_one_attached :csv_file,
|
|
65
|
-
service: MaintenanceTasks.active_storage_service
|
|
66
|
-
elsif respond_to?(:has_one_attached)
|
|
67
|
-
has_one_attached :csv_file
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Sets the run status to enqueued, making sure the transition is validated
|
|
71
|
-
# in case it's already enqueued.
|
|
72
|
-
#
|
|
73
|
-
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
74
|
-
# is encountered.
|
|
75
|
-
def enqueued!
|
|
76
|
-
with_stale_object_retry do
|
|
77
|
-
status_will_change!
|
|
78
|
-
super
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
CALLBACKS_TRANSITION = {
|
|
83
|
-
cancelled: :cancel,
|
|
84
|
-
interrupted: :interrupt,
|
|
85
|
-
paused: :pause,
|
|
86
|
-
succeeded: :complete,
|
|
87
|
-
}.transform_keys(&:to_s)
|
|
88
|
-
|
|
89
|
-
DELAYS_PER_ATTEMPT = [0.1, 0.2, 0.4, 0.8, 1.6]
|
|
90
|
-
MAX_RETRIES = DELAYS_PER_ATTEMPT.size
|
|
91
|
-
|
|
92
|
-
private_constant :CALLBACKS_TRANSITION, :DELAYS_PER_ATTEMPT, :MAX_RETRIES
|
|
93
|
-
|
|
94
|
-
# Saves the run, persisting the transition of its status, and all other
|
|
95
|
-
# changes to the object.
|
|
96
|
-
def persist_transition
|
|
97
|
-
retry_count = 0
|
|
98
|
-
begin
|
|
99
|
-
save!
|
|
100
|
-
rescue ActiveRecord::StaleObjectError
|
|
101
|
-
if retry_count < MAX_RETRIES
|
|
102
|
-
sleep(DELAYS_PER_ATTEMPT[retry_count])
|
|
103
|
-
retry_count += 1
|
|
104
|
-
|
|
105
|
-
success = succeeded?
|
|
106
|
-
reload_status
|
|
107
|
-
if success
|
|
108
|
-
self.status = :succeeded
|
|
109
|
-
else
|
|
110
|
-
job_shutdown
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
retry
|
|
114
|
-
else
|
|
115
|
-
raise
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
callback = CALLBACKS_TRANSITION[status]
|
|
120
|
-
run_task_callbacks(callback) if callback
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
# Increments +tick_count+ by +number_of_ticks+ and +time_running+ by
|
|
124
|
-
# +duration+, both directly in the DB.
|
|
125
|
-
# The attribute values are not set in the current instance, you need
|
|
126
|
-
# to reload the record.
|
|
127
|
-
#
|
|
128
|
-
# @param number_of_ticks [Integer] number of ticks to add to tick_count.
|
|
129
|
-
# @param duration [Float] the time in seconds that elapsed since the last
|
|
130
|
-
# increment of ticks.
|
|
131
|
-
def persist_progress(number_of_ticks, duration)
|
|
132
|
-
self.class.update_counters(
|
|
133
|
-
id,
|
|
134
|
-
tick_count: number_of_ticks,
|
|
135
|
-
time_running: duration,
|
|
136
|
-
touch: true,
|
|
137
|
-
)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Marks the run as errored and persists the error data.
|
|
141
|
-
#
|
|
142
|
-
# @param error [StandardError] the Error being persisted.
|
|
143
|
-
def persist_error(error)
|
|
144
|
-
with_stale_object_retry do
|
|
145
|
-
self.started_at ||= Time.now
|
|
146
|
-
update!(
|
|
147
|
-
status: :errored,
|
|
148
|
-
error_class: truncate(:error_class, error.class.name),
|
|
149
|
-
error_message: truncate(:error_message, error.message),
|
|
150
|
-
backtrace: MaintenanceTasks.backtrace_cleaner.clean(error.backtrace),
|
|
151
|
-
ended_at: Time.now,
|
|
152
|
-
)
|
|
153
|
-
end
|
|
154
|
-
run_error_callback
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# Refreshes the status and lock version attributes on the Active Record
|
|
158
|
-
# object, and ensures ActiveModel::Dirty doesn't mark the object as changed.
|
|
159
|
-
#
|
|
160
|
-
# This allows us to get the Run's most up-to-date status without needing
|
|
161
|
-
# to reload the entire record.
|
|
162
|
-
#
|
|
163
|
-
# @return [MaintenanceTasks::Run] the Run record with its updated status.
|
|
164
|
-
def reload_status
|
|
165
|
-
columns_to_reload = if locking_enabled?
|
|
166
|
-
[:status, self.class.locking_column]
|
|
167
|
-
else
|
|
168
|
-
[:status]
|
|
169
|
-
end
|
|
170
|
-
updated_status, updated_lock_version = self.class.uncached do
|
|
171
|
-
self.class.where(id: id).pluck(*columns_to_reload).first
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
self.status = updated_status
|
|
175
|
-
if updated_lock_version
|
|
176
|
-
self[self.class.locking_column] = updated_lock_version
|
|
177
|
-
end
|
|
178
|
-
clear_attribute_changes(columns_to_reload)
|
|
179
|
-
self
|
|
180
|
-
end
|
|
181
|
-
|
|
182
|
-
# Returns whether the Run is stopping, which is defined as having a status
|
|
183
|
-
# of pausing or cancelling. The status of cancelled is also considered
|
|
184
|
-
# stopping since a Run can be cancelled while its job still exists in the
|
|
185
|
-
# queue, and we want to handle it the same way as a cancelling run.
|
|
186
|
-
#
|
|
187
|
-
# @return [Boolean] whether the Run is stopping.
|
|
188
|
-
def stopping?
|
|
189
|
-
STOPPING_STATUSES.include?(status.to_sym)
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Returns whether the Run is stopped, which is defined as having a status of
|
|
193
|
-
# paused, succeeded, cancelled, or errored.
|
|
194
|
-
#
|
|
195
|
-
# @return [Boolean] whether the Run is stopped.
|
|
196
|
-
def stopped?
|
|
197
|
-
completed? || paused?
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Returns whether the Run has been started, which is indicated by the
|
|
201
|
-
# started_at timestamp being present.
|
|
202
|
-
#
|
|
203
|
-
# @return [Boolean] whether the Run was started.
|
|
204
|
-
def started?
|
|
205
|
-
started_at.present?
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Returns whether the Run is completed, which is defined as
|
|
209
|
-
# having a status of succeeded, cancelled, or errored.
|
|
210
|
-
#
|
|
211
|
-
# @return [Boolean] whether the Run is completed.
|
|
212
|
-
def completed?
|
|
213
|
-
COMPLETED_STATUSES.include?(status.to_sym)
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
# Returns whether the Run is active, which is defined as
|
|
217
|
-
# having a status of enqueued, running, pausing, cancelling,
|
|
218
|
-
# paused or interrupted.
|
|
219
|
-
#
|
|
220
|
-
# @return [Boolean] whether the Run is active.
|
|
221
|
-
def active?
|
|
222
|
-
ACTIVE_STATUSES.include?(status.to_sym)
|
|
223
|
-
end
|
|
224
|
-
|
|
225
|
-
# Returns the duration left for the Run to finish based on the number of
|
|
226
|
-
# ticks left and the average time needed to process a tick. Returns nil if
|
|
227
|
-
# the Run is completed, or if tick_count or tick_total is zero.
|
|
228
|
-
#
|
|
229
|
-
# @return [ActiveSupport::Duration] the estimated duration left for the Run
|
|
230
|
-
# to finish.
|
|
231
|
-
def time_to_completion
|
|
232
|
-
return if completed? || tick_count == 0 || tick_total.to_i == 0
|
|
233
|
-
|
|
234
|
-
processed_per_second = (tick_count.to_f / time_running)
|
|
235
|
-
ticks_left = (tick_total - tick_count)
|
|
236
|
-
seconds_to_finished = ticks_left / processed_per_second
|
|
237
|
-
seconds_to_finished.seconds
|
|
238
|
-
end
|
|
239
|
-
|
|
240
|
-
# Marks a Run as running.
|
|
241
|
-
#
|
|
242
|
-
# If the run is stopping already, it will not transition to running.
|
|
243
|
-
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
244
|
-
# is encountered.
|
|
245
|
-
def running
|
|
246
|
-
if locking_enabled?
|
|
247
|
-
with_stale_object_retry do
|
|
248
|
-
running! unless stopping?
|
|
249
|
-
end
|
|
250
|
-
else
|
|
251
|
-
# Preserve swap-and-replace solution for data races until users
|
|
252
|
-
# run migration to upgrade to optimistic locking solution
|
|
253
|
-
return if stopping?
|
|
254
|
-
|
|
255
|
-
updated = self.class.where(id: id).where.not(status: STOPPING_STATUSES)
|
|
256
|
-
.update_all(status: :running, updated_at: Time.now) > 0
|
|
257
|
-
if updated
|
|
258
|
-
self.status = :running
|
|
259
|
-
clear_attribute_changes([:status])
|
|
260
|
-
else
|
|
261
|
-
reload_status
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
# Starts a Run, setting its started_at timestamp and tick_total.
|
|
267
|
-
#
|
|
268
|
-
# @param count [Integer] the total iterations to be performed, as
|
|
269
|
-
# specified by the Task.
|
|
270
|
-
def start(count)
|
|
271
|
-
with_stale_object_retry do
|
|
272
|
-
update!(started_at: Time.now, tick_total: count)
|
|
273
|
-
end
|
|
274
|
-
|
|
275
|
-
task.run_callbacks(:start)
|
|
276
|
-
end
|
|
277
|
-
|
|
278
|
-
# Handles transitioning the status on a Run when the job shuts down.
|
|
279
|
-
def job_shutdown
|
|
280
|
-
if cancelling?
|
|
281
|
-
self.status = :cancelled
|
|
282
|
-
self.ended_at = Time.now
|
|
283
|
-
elsif pausing?
|
|
284
|
-
self.status = :paused
|
|
285
|
-
elsif cancelled?
|
|
286
|
-
else
|
|
287
|
-
self.status = :interrupted
|
|
288
|
-
end
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Handles the completion of a Run, setting a status of succeeded and the
|
|
292
|
-
# ended_at timestamp.
|
|
293
|
-
def complete
|
|
294
|
-
self.status = :succeeded
|
|
295
|
-
self.ended_at = Time.now
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
# Cancels a Run.
|
|
299
|
-
#
|
|
300
|
-
# If the Run is paused, it will transition directly to cancelled, since the
|
|
301
|
-
# Task is not being performed. In this case, the ended_at timestamp
|
|
302
|
-
# will be updated.
|
|
303
|
-
#
|
|
304
|
-
# If the Run is not paused, the Run will transition to cancelling.
|
|
305
|
-
#
|
|
306
|
-
# If the Run is already cancelling, and has last been updated more than 5
|
|
307
|
-
# minutes ago, it will transition to cancelled, and the ended_at timestamp
|
|
308
|
-
# will be updated.
|
|
309
|
-
def cancel
|
|
310
|
-
with_stale_object_retry do
|
|
311
|
-
if paused? || stuck?
|
|
312
|
-
self.status = :cancelled
|
|
313
|
-
self.ended_at = Time.now
|
|
314
|
-
persist_transition
|
|
315
|
-
else
|
|
316
|
-
cancelling!
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# Marks a Run as pausing.
|
|
322
|
-
#
|
|
323
|
-
# If the Run has been stuck on pausing for more than 5 minutes, it forces
|
|
324
|
-
# the transition to paused. The ended_at timestamp will be updated.
|
|
325
|
-
#
|
|
326
|
-
# Rescues and retries status transition if an ActiveRecord::StaleObjectError
|
|
327
|
-
# is encountered.
|
|
328
|
-
def pause
|
|
329
|
-
with_stale_object_retry do
|
|
330
|
-
if stuck?
|
|
331
|
-
self.status = :paused
|
|
332
|
-
persist_transition
|
|
333
|
-
else
|
|
334
|
-
pausing!
|
|
335
|
-
end
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
|
|
339
|
-
# Returns whether a Run is stuck, which is defined as having a status of
|
|
340
|
-
# cancelling or pausing, and not having been updated in the last 5 minutes.
|
|
341
|
-
#
|
|
342
|
-
# @return [Boolean] whether the Run is stuck.
|
|
343
|
-
def stuck?
|
|
344
|
-
(cancelling? || pausing?) && updated_at <= MaintenanceTasks.stuck_task_duration.ago
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
# Performs validation on the task_name attribute.
|
|
348
|
-
# A Run must be associated with a valid Task to be valid.
|
|
349
|
-
# In order to confirm that, the Task is looked up by name.
|
|
350
|
-
def task_name_belongs_to_a_valid_task
|
|
351
|
-
Task.named(task_name)
|
|
352
|
-
rescue Task::NotFoundError
|
|
353
|
-
errors.add(:task_name, "must be the name of an existing Task.")
|
|
354
|
-
end
|
|
355
|
-
|
|
356
|
-
# Performs validation on the presence of a :csv_file attachment.
|
|
357
|
-
# A Run for a Task that uses CsvCollection must have an attached :csv_file
|
|
358
|
-
# to be valid. Conversely, a Run for a Task that doesn't use CsvCollection
|
|
359
|
-
# should not have an attachment to be valid. The appropriate error is added
|
|
360
|
-
# if the Run does not meet the above criteria.
|
|
361
|
-
def csv_attachment_presence
|
|
362
|
-
if Task.named(task_name).has_csv_content? && !csv_file.attached?
|
|
363
|
-
errors.add(:csv_file, "must be attached to CSV Task.")
|
|
364
|
-
elsif !Task.named(task_name).has_csv_content? && csv_file.present?
|
|
365
|
-
errors.add(:csv_file, "should not be attached to non-CSV Task.")
|
|
366
|
-
end
|
|
367
|
-
rescue Task::NotFoundError
|
|
368
|
-
nil
|
|
369
|
-
end
|
|
370
|
-
|
|
371
|
-
# Performs validation on the content type of the :csv_file attachment.
|
|
372
|
-
# A Run for a Task that uses CsvCollection must have a present :csv_file
|
|
373
|
-
# and a content type of "text/csv" to be valid. The appropriate error is
|
|
374
|
-
# added if the Run does not meet the above criteria.
|
|
375
|
-
def csv_content_type
|
|
376
|
-
if csv_file.present? && csv_file.content_type != "text/csv"
|
|
377
|
-
errors.add(:csv_file, "must be a CSV")
|
|
378
|
-
end
|
|
379
|
-
rescue Task::NotFoundError
|
|
380
|
-
nil
|
|
381
|
-
end
|
|
382
|
-
|
|
383
|
-
# Performs validation on the arguments to use for the Task. If the Task is
|
|
384
|
-
# invalid, the errors are added to the Run.
|
|
385
|
-
def validate_task_arguments
|
|
386
|
-
arguments_match_task_attributes if arguments.present?
|
|
387
|
-
if task.invalid?
|
|
388
|
-
error_messages = task.errors
|
|
389
|
-
.map { |error| "#{error.attribute.inspect} #{error.message}" }
|
|
390
|
-
errors.add(
|
|
391
|
-
:arguments,
|
|
392
|
-
"are invalid: #{error_messages.join("; ")}",
|
|
393
|
-
)
|
|
394
|
-
end
|
|
395
|
-
rescue Task::NotFoundError
|
|
396
|
-
nil
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
# Fetches the attached ActiveStorage CSV file for the run. Checks first
|
|
400
|
-
# whether the ActiveStorage::Attachment table exists so that we are
|
|
401
|
-
# compatible with apps that are not using ActiveStorage.
|
|
402
|
-
#
|
|
403
|
-
# @return [ActiveStorage::Attached::One] the attached CSV file
|
|
404
|
-
def csv_file
|
|
405
|
-
return unless defined?(ActiveStorage)
|
|
406
|
-
return unless ActiveStorage::Attachment.table_exists?
|
|
407
|
-
|
|
408
|
-
super
|
|
409
|
-
end
|
|
410
|
-
|
|
411
|
-
# Returns a Task instance for this Run. Assigns any attributes to the Task
|
|
412
|
-
# based on the Run's parameters. Note that the Task instance is not supplied
|
|
413
|
-
# with :csv_content yet if it's a CSV Task. This is done in the job, since
|
|
414
|
-
# downloading the CSV file can take some time.
|
|
415
|
-
#
|
|
416
|
-
# @return [Task] a Task instance.
|
|
417
|
-
def task
|
|
418
|
-
@task ||= begin
|
|
419
|
-
task = Task.named(task_name).new
|
|
420
|
-
if task.attribute_names.any? && arguments.present?
|
|
421
|
-
task.assign_attributes(arguments)
|
|
422
|
-
end
|
|
423
|
-
|
|
424
|
-
task.metadata = metadata
|
|
425
|
-
task
|
|
426
|
-
rescue ActiveModel::UnknownAttributeError
|
|
427
|
-
task
|
|
428
|
-
end
|
|
429
|
-
end
|
|
430
|
-
|
|
431
|
-
# Returns all the run arguments with sensitive information masked.
|
|
432
|
-
#
|
|
433
|
-
# @return [Hash] The masked arguments.
|
|
434
|
-
def masked_arguments
|
|
435
|
-
return unless arguments.present?
|
|
436
|
-
|
|
437
|
-
argument_filter.filter(arguments)
|
|
438
|
-
end
|
|
439
|
-
|
|
440
|
-
private
|
|
441
|
-
|
|
442
|
-
def instrument_status_change
|
|
443
|
-
return unless status_previously_changed? || id_previously_changed?
|
|
444
|
-
return if running? || pausing? || cancelling? || interrupted?
|
|
445
|
-
|
|
446
|
-
attr = {
|
|
447
|
-
run_id: id,
|
|
448
|
-
job_id: job_id,
|
|
449
|
-
task_name: task_name,
|
|
450
|
-
arguments: arguments,
|
|
451
|
-
metadata: metadata,
|
|
452
|
-
time_running: time_running,
|
|
453
|
-
started_at: started_at,
|
|
454
|
-
ended_at: ended_at,
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
attr[:error] = {
|
|
458
|
-
message: error_message,
|
|
459
|
-
class: error_class,
|
|
460
|
-
backtrace: backtrace,
|
|
461
|
-
} if errored?
|
|
462
|
-
|
|
463
|
-
ActiveSupport::Notifications.instrument("#{status}.maintenance_tasks", attr)
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def run_task_callbacks(callback)
|
|
467
|
-
task.run_callbacks(callback)
|
|
468
|
-
rescue Task::NotFoundError
|
|
469
|
-
nil
|
|
470
|
-
end
|
|
471
|
-
|
|
472
|
-
def run_error_callback
|
|
473
|
-
task.run_callbacks(:error)
|
|
474
|
-
rescue
|
|
475
|
-
nil
|
|
476
|
-
end
|
|
477
|
-
|
|
478
|
-
def arguments_match_task_attributes
|
|
479
|
-
invalid_argument_keys = arguments.keys - task.attribute_names
|
|
480
|
-
if invalid_argument_keys.any?
|
|
481
|
-
error_message = <<~MSG.squish
|
|
482
|
-
Unknown parameters: #{invalid_argument_keys.map(&:to_sym).join(", ")}
|
|
483
|
-
MSG
|
|
484
|
-
errors.add(:base, error_message)
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
|
|
488
|
-
def truncate(attribute_name, value)
|
|
489
|
-
limit = self.class.column_for_attribute(attribute_name).limit
|
|
490
|
-
return value unless limit
|
|
491
|
-
|
|
492
|
-
value&.first(limit)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
|
-
def argument_filter
|
|
496
|
-
@argument_filter ||= ActiveSupport::ParameterFilter.new(
|
|
497
|
-
Rails.application.config.filter_parameters + task.masked_arguments,
|
|
498
|
-
)
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
def with_stale_object_retry(retry_count = 0)
|
|
502
|
-
yield
|
|
503
|
-
rescue ActiveRecord::StaleObjectError
|
|
504
|
-
if retry_count < MAX_RETRIES
|
|
505
|
-
sleep(stale_object_retry_delay(retry_count))
|
|
506
|
-
retry_count += 1
|
|
507
|
-
reload_status
|
|
508
|
-
|
|
509
|
-
retry
|
|
510
|
-
else
|
|
511
|
-
raise
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
def stale_object_retry_delay(retry_count)
|
|
516
|
-
delay = DELAYS_PER_ATTEMPT[retry_count]
|
|
517
|
-
# Add jitter (±25% randomization) to prevent thundering herd
|
|
518
|
-
jitter = delay * 0.25
|
|
519
|
-
delay + (rand * 2 - 1) * jitter
|
|
520
|
-
end
|
|
8
|
+
include RunConcern
|
|
521
9
|
end
|
|
522
10
|
end
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<nav class="navbar
|
|
1
|
+
<nav class="navbar" role="navigation" aria-label="main navigation">
|
|
2
2
|
<div class="navbar-brand">
|
|
3
3
|
<%= link_to 'Maintenance Tasks', root_path, class: 'navbar-item is-size-4 has-text-weight-semibold has-text-danger' %>
|
|
4
4
|
</div>
|
|
@@ -16,20 +16,44 @@
|
|
|
16
16
|
<%= csrf_meta_tags %>
|
|
17
17
|
|
|
18
18
|
<%=
|
|
19
|
-
stylesheet_link_tag(URI.join(controller.class::BULMA_CDN, "npm/bulma@1.0.
|
|
19
|
+
stylesheet_link_tag(URI.join(controller.class::BULMA_CDN, "npm/bulma@1.0.4/css/bulma.min.css"),
|
|
20
20
|
media: :all,
|
|
21
|
-
integrity: "sha256-
|
|
21
|
+
integrity: "sha256-Z/om3xyp6V2PKtx8BPobFfo9JCV0cOvBDMaLmquRS+4=",
|
|
22
22
|
crossorigin: "anonymous") unless request.xhr?
|
|
23
23
|
%>
|
|
24
24
|
|
|
25
25
|
<style>
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
:root {
|
|
27
|
+
--ruby-comment: #6a737d;
|
|
28
|
+
--ruby-const: #e36209;
|
|
29
|
+
--ruby-embexpr: #24292e;
|
|
30
|
+
--ruby-ident: #6f42c1;
|
|
31
|
+
--ruby-number: #005cc5;
|
|
32
|
+
--ruby-keyword: #d73a49;
|
|
33
|
+
--ruby-string: #032f62;
|
|
34
|
+
--required-color: #ff6685;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@media (prefers-color-scheme: dark) {
|
|
38
|
+
:root {
|
|
39
|
+
--ruby-comment: #8b949e;
|
|
40
|
+
--ruby-const: #ffa657;
|
|
41
|
+
--ruby-embexpr: #c9d1d9;
|
|
42
|
+
--ruby-ident: #d2a8ff;
|
|
43
|
+
--ruby-number: #79c0ff;
|
|
44
|
+
--ruby-keyword: #ff7b72;
|
|
45
|
+
--ruby-string: #a5d6ff;
|
|
46
|
+
--required-color: #ff6685;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ruby-comment { color: var(--ruby-comment); }
|
|
51
|
+
.ruby-const { color: var(--ruby-const); }
|
|
52
|
+
.ruby-embexpr-beg, .ruby-embexpr-end, .ruby-period { color: var(--ruby-embexpr); }
|
|
53
|
+
.ruby-ident, .ruby-symbeg { color: var(--ruby-ident); }
|
|
54
|
+
.ruby-ivar, .ruby-cvar, .ruby-gvar, .ruby-int, .ruby-imaginary, .ruby-float, .ruby-rational { color: var(--ruby-number); }
|
|
55
|
+
.ruby-kw { color: var(--ruby-keyword); }
|
|
56
|
+
.ruby-label, .ruby-tstring-beg, .ruby-tstring-content, .ruby-tstring-end { color: var(--ruby-string); }
|
|
33
57
|
|
|
34
58
|
.select, select { width: 100%; }
|
|
35
59
|
summary { cursor: pointer; }
|
|
@@ -52,12 +76,19 @@
|
|
|
52
76
|
}
|
|
53
77
|
|
|
54
78
|
.box {
|
|
55
|
-
box-shadow: 0
|
|
56
|
-
0 2px 4px -
|
|
79
|
+
box-shadow: 0 0 6px 0 #0000001a,
|
|
80
|
+
0 2px 4px -1px #0000001a;
|
|
57
81
|
}
|
|
82
|
+
|
|
83
|
+
@media (prefers-color-scheme: dark) {
|
|
84
|
+
.box {
|
|
85
|
+
background-color: var(--bulma-background);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
58
89
|
.label.is-required:after {
|
|
59
90
|
content: " (required)";
|
|
60
|
-
color:
|
|
91
|
+
color: var(--required-color);
|
|
61
92
|
font-size: 12px;
|
|
62
93
|
}
|
|
63
94
|
</style>
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: maintenance_tasks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.14.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shopify Engineering
|
|
@@ -122,6 +122,7 @@ files:
|
|
|
122
122
|
- app/helpers/maintenance_tasks/tasks_helper.rb
|
|
123
123
|
- app/jobs/concerns/maintenance_tasks/task_job_concern.rb
|
|
124
124
|
- app/jobs/maintenance_tasks/task_job.rb
|
|
125
|
+
- app/models/concerns/maintenance_tasks/run_concern.rb
|
|
125
126
|
- app/models/maintenance_tasks/application_record.rb
|
|
126
127
|
- app/models/maintenance_tasks/batch_csv_collection_builder.rb
|
|
127
128
|
- app/models/maintenance_tasks/csv_collection_builder.rb
|
|
@@ -182,7 +183,7 @@ homepage: https://github.com/Shopify/maintenance_tasks
|
|
|
182
183
|
licenses:
|
|
183
184
|
- MIT
|
|
184
185
|
metadata:
|
|
185
|
-
source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v2.
|
|
186
|
+
source_code_uri: https://github.com/Shopify/maintenance_tasks/tree/v2.14.0
|
|
186
187
|
allowed_push_host: https://rubygems.org
|
|
187
188
|
rdoc_options: []
|
|
188
189
|
require_paths:
|
|
@@ -198,7 +199,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
198
199
|
- !ruby/object:Gem::Version
|
|
199
200
|
version: '0'
|
|
200
201
|
requirements: []
|
|
201
|
-
rubygems_version:
|
|
202
|
+
rubygems_version: 4.0.6
|
|
202
203
|
specification_version: 4
|
|
203
204
|
summary: A Rails engine for queuing and managing maintenance tasks
|
|
204
205
|
test_files: []
|