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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8402f1f4b8919e892a6334373ad397d49b5c6c78893f2f107b990b90653435d1
4
- data.tar.gz: 1459d5b92233a1196aeb83c80bec9da525f04e975a50f6a415cdcd8dc647bd59
3
+ metadata.gz: 7ceffb83ecb318ecf0edd66c78f55d4dd51c6528d4faf72d6e7ca66efa83db02
4
+ data.tar.gz: 360bc77f347ac909ed96a30495f965cc908cc5fc7956f1edfa8c3b2aba385607
5
5
  SHA512:
6
- metadata.gz: 3dff16b85a650f4d73cf79df20267d1278b1181f2ba6717742581909ebfd39145dfdd9b9cf1453edaa785474fb6f7dda87af2bad534e6398bf010945b02f2da3
7
- data.tar.gz: c7b75c1edc684fad66c5080ee3642fe469038e1f4ab2329ead0fd3c65e5f0ca2a940250dc988658dce672e2de0d056741b9a317310afa23165ba27794722ce1d
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(task)
246
- task.csv_content.count("\n") - 1
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-WHHDQLdkleXnAN5zs0GDXC5ls41CHUaVsJtVpaNx+EM='",
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
- # Various statuses a run can be in.
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 is-light" role="navigation" aria-label="main navigation">
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.3/css/versions/bulma-no-dark-mode.min.css"),
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-HCNMQcqH/4MnGR0EYg2S3/BXYMM1z9lrFV10ANRd79o",
21
+ integrity: "sha256-Z/om3xyp6V2PKtx8BPobFfo9JCV0cOvBDMaLmquRS+4=",
22
22
  crossorigin: "anonymous") unless request.xhr?
23
23
  %>
24
24
 
25
25
  <style>
26
- .ruby-comment { color: #6a737d;}
27
- .ruby-const { color: #e36209; }
28
- .ruby-embexpr-beg, .ruby-embexpr-end, .ruby-period { color: #24292e; }
29
- .ruby-ident, .ruby-symbeg { color: #6f42c1; }
30
- .ruby-ivar, .ruby-cvar, .ruby-gvar, .ruby-int, .ruby-imaginary, .ruby-float, .ruby-rational { color: #005cc5; }
31
- .ruby-kw { color: #d73a49; }
32
- .ruby-label, .ruby-tstring-beg, .ruby-tstring-content, .ruby-tstring-end { color: #032f62; }
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 4px 6px -1px #0000001a,
56
- 0 2px 4px -2px #0000001a;
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: #ff6685;
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.13.0
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.13.0
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: 3.7.2
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: []