maintenance_tasks 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE.md +18 -0
  3. data/README.md +90 -32
  4. data/app/controllers/maintenance_tasks/application_controller.rb +2 -4
  5. data/app/controllers/maintenance_tasks/tasks_controller.rb +1 -1
  6. data/app/helpers/maintenance_tasks/application_helper.rb +2 -13
  7. data/app/helpers/maintenance_tasks/tasks_helper.rb +17 -16
  8. data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +150 -0
  9. data/app/jobs/maintenance_tasks/task_job.rb +1 -129
  10. data/app/models/maintenance_tasks/csv_collection.rb +1 -1
  11. data/app/models/maintenance_tasks/progress.rb +13 -11
  12. data/app/models/maintenance_tasks/run.rb +26 -4
  13. data/app/models/maintenance_tasks/runner.rb +2 -2
  14. data/app/models/maintenance_tasks/runs_page.rb +55 -0
  15. data/app/models/maintenance_tasks/task_data.rb +3 -3
  16. data/app/tasks/maintenance_tasks/task.rb +24 -1
  17. data/app/validators/maintenance_tasks/run_status_validator.rb +11 -11
  18. data/app/views/maintenance_tasks/runs/_info.html.erb +1 -1
  19. data/app/views/maintenance_tasks/runs/info/_running.html.erb +0 -2
  20. data/app/views/maintenance_tasks/tasks/show.html.erb +3 -3
  21. data/config/routes.rb +4 -4
  22. data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -1
  23. data/exe/maintenance_tasks +2 -2
  24. data/lib/generators/maintenance_tasks/install_generator.rb +4 -4
  25. data/lib/generators/maintenance_tasks/task_generator.rb +10 -10
  26. data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +7 -5
  27. data/lib/maintenance_tasks.rb +51 -29
  28. data/lib/maintenance_tasks/cli.rb +3 -3
  29. data/lib/maintenance_tasks/engine.rb +4 -3
  30. metadata +10 -21
  31. data/Rakefile +0 -29
@@ -3,134 +3,6 @@
3
3
  module MaintenanceTasks
4
4
  # Base class that is inherited by the host application's task classes.
5
5
  class TaskJob < ActiveJob::Base
6
- include JobIteration::Iteration
7
-
8
- before_perform(:before_perform)
9
-
10
- on_start(:on_start)
11
- on_complete(:on_complete)
12
- on_shutdown(:on_shutdown)
13
-
14
- after_perform(:after_perform)
15
-
16
- rescue_from StandardError, with: :on_error
17
-
18
- class << self
19
- # Overrides ActiveJob::Exceptions.retry_on to declare it unsupported.
20
- # The use of rescue_from prevents retry_on from being usable.
21
- def retry_on(*, **)
22
- raise NotImplementedError, 'retry_on is not supported'
23
- end
24
- end
25
-
26
- private
27
-
28
- def build_enumerator(_run, cursor:)
29
- cursor ||= @run.cursor
30
- collection = @task.collection
31
-
32
- case collection
33
- when ActiveRecord::Relation
34
- enumerator_builder.active_record_on_records(collection, cursor: cursor)
35
- when Array
36
- enumerator_builder.build_array_enumerator(collection, cursor: cursor)
37
- when CSV
38
- JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
39
- else
40
- raise ArgumentError, "#{@task.class.name}#collection must be either "\
41
- 'an Active Record Relation, Array, or CSV.'
42
- end
43
- end
44
-
45
- # Performs task iteration logic for the current input returned by the
46
- # enumerator.
47
- #
48
- # @param input [Object] the current element from the enumerator.
49
- # @param _run [Run] the current Run, passed as an argument by Job Iteration.
50
- def each_iteration(input, _run)
51
- throw(:abort, :skip_complete_callbacks) if @run.stopping?
52
- task_iteration(input)
53
- @ticker.tick
54
- @run.reload_status
55
- end
56
-
57
- def task_iteration(input)
58
- @task.process(input)
59
- rescue => error
60
- @errored_element = input
61
- raise error
62
- end
63
-
64
- def before_perform
65
- @run = arguments.first
66
- @task = Task.named(@run.task_name).new
67
- if @task.respond_to?(:csv_content=)
68
- @task.csv_content = @run.csv_file.download
69
- end
70
- @run.job_id = job_id
71
-
72
- @run.running! unless @run.stopping?
73
-
74
- @ticker = Ticker.new(MaintenanceTasks.ticker_delay) do |ticks, duration|
75
- @run.persist_progress(ticks, duration)
76
- end
77
- end
78
-
79
- def on_start
80
- @run.update!(started_at: Time.now, tick_total: @task.count)
81
- end
82
-
83
- def on_complete
84
- @run.status = :succeeded
85
- @run.ended_at = Time.now
86
- end
87
-
88
- def on_shutdown
89
- if @run.cancelling?
90
- @run.status = :cancelled
91
- @run.ended_at = Time.now
92
- else
93
- @run.status = @run.pausing? ? :paused : :interrupted
94
- @run.cursor = cursor_position
95
- end
96
-
97
- @ticker.persist
98
- end
99
-
100
- # We are reopening a private part of Job Iteration's API here, so we should
101
- # ensure the method is still defined upstream. This way, in the case where
102
- # the method changes upstream, we catch it at load time instead of at
103
- # runtime while calling `super`.
104
- unless private_method_defined?(:reenqueue_iteration_job)
105
- error_message = <<~HEREDOC
106
- JobIteration::Iteration#reenqueue_iteration_job is expected to be
107
- defined. Upgrading the maintenance_tasks gem should solve this problem.
108
- HEREDOC
109
- raise error_message
110
- end
111
- def reenqueue_iteration_job(should_ignore: true)
112
- super() unless should_ignore
113
- @reenqueue_iteration_job = true
114
- end
115
-
116
- def after_perform
117
- @run.save!
118
- if defined?(@reenqueue_iteration_job) && @reenqueue_iteration_job
119
- reenqueue_iteration_job(should_ignore: false)
120
- end
121
- end
122
-
123
- def on_error(error)
124
- @ticker.persist if defined?(@ticker)
125
- @run.persist_error(error)
126
-
127
- task_context = {
128
- task_name: @run.task_name,
129
- started_at: @run.started_at,
130
- ended_at: @run.ended_at,
131
- }
132
- errored_element = @errored_element if defined?(@errored_element)
133
- MaintenanceTasks.error_handler.call(error, task_context, errored_element)
134
- end
6
+ include TaskJobConcern
135
7
  end
136
8
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'csv'
3
+ require "csv"
4
4
 
5
5
  module MaintenanceTasks
6
6
  # Module that is included into Task classes by Task.csv_collection for
@@ -4,6 +4,7 @@ module MaintenanceTasks
4
4
  # This class generates progress information about a Run.
5
5
  class Progress
6
6
  include ActiveSupport::NumberHelper
7
+ include ActionView::Helpers::TextHelper
7
8
 
8
9
  # Sets the Progress initial state with a Run.
9
10
  #
@@ -41,22 +42,23 @@ module MaintenanceTasks
41
42
  estimatable? ? @run.tick_total : @run.tick_count
42
43
  end
43
44
 
44
- # The title for the progress information. This is a text that describes the
45
- # progress of the Run so far. It includes the percentage that is done out of
46
- # the maximum, if an estimate is possible.
45
+ # The text containing progress information. This describes the progress of
46
+ # the Run so far. It includes the percentage done out of the maximum, if an
47
+ # estimate is possible.
47
48
  #
48
- # @return [String] the title for the Run progress.
49
- def title
49
+ # @return [String] the text for the Run progress.
50
+ def text
51
+ count = @run.tick_count
52
+ total = @run.tick_total
50
53
  if !total?
51
- "Processed #{@run.tick_count} #{'item'.pluralize(@run.tick_count)}."
54
+ "Processed #{pluralize(count, "item")}."
52
55
  elsif over_total?
53
- "Processed #{@run.tick_count} #{'item'.pluralize(@run.tick_count)} " \
54
- "(expected #{@run.tick_total})."
56
+ "Processed #{pluralize(count, "item")} (expected #{total})."
55
57
  else
56
- percentage = 100.0 * @run.tick_count / @run.tick_total
58
+ percentage = 100.0 * count / total
57
59
 
58
- "Processed #{@run.tick_count} out of #{@run.tick_total} "\
59
- "(#{number_to_percentage(percentage, precision: 0)})"
60
+ "Processed #{count} out of #{pluralize(total, "item")} "\
61
+ "(#{number_to_percentage(percentage, precision: 0)})."
60
62
  end
61
63
  end
62
64
 
@@ -41,9 +41,20 @@ module MaintenanceTasks
41
41
 
42
42
  scope :active, -> { where(status: ACTIVE_STATUSES) }
43
43
 
44
+ # Ensure ActiveStorage is in use before preloading the attachments
45
+ scope :with_attached_csv, -> do
46
+ return unless defined?(ActiveStorage)
47
+ with_attached_csv_file if ActiveStorage::Attachment.table_exists?
48
+ end
49
+
44
50
  validates_with RunStatusValidator, on: :update
45
51
 
46
- has_one_attached :csv_file
52
+ if MaintenanceTasks.active_storage_service.present?
53
+ has_one_attached :csv_file,
54
+ service: MaintenanceTasks.active_storage_service
55
+ elsif respond_to?(:has_one_attached)
56
+ has_one_attached :csv_file
57
+ end
47
58
 
48
59
  # Sets the run status to enqueued, making sure the transition is validated
49
60
  # in case it's already enqueued.
@@ -187,12 +198,23 @@ module MaintenanceTasks
187
198
  # if the Run does not meet the above criteria.
188
199
  def csv_attachment_presence
189
200
  if Task.named(task_name) < CsvCollection && !csv_file.attached?
190
- errors.add(:csv_file, 'must be attached to CSV Task.')
191
- elsif !(Task.named(task_name) < CsvCollection) && csv_file.attached?
192
- errors.add(:csv_file, 'should not be attached to non-CSV Task.')
201
+ errors.add(:csv_file, "must be attached to CSV Task.")
202
+ elsif !(Task.named(task_name) < CsvCollection) && csv_file.present?
203
+ errors.add(:csv_file, "should not be attached to non-CSV Task.")
193
204
  end
194
205
  rescue Task::NotFoundError
195
206
  nil
196
207
  end
208
+
209
+ # Fetches the attached ActiveStorage CSV file for the run. Checks first
210
+ # whether the ActiveStorage::Attachment table exists so that we are
211
+ # compatible with apps that are not using ActiveStorage.
212
+ #
213
+ # @return [ActiveStorage::Attached::One] the attached CSV file
214
+ def csv_file
215
+ return unless defined?(ActiveStorage)
216
+ return unless ActiveStorage::Attachment.table_exists?
217
+ super
218
+ end
197
219
  end
198
220
  end
@@ -8,7 +8,7 @@ module MaintenanceTasks
8
8
  # @deprecated Use {Runner} directly instead.
9
9
  def new
10
10
  ActiveSupport::Deprecation.warn(
11
- 'Use Runner.run instead of Runner.new.run'
11
+ "Use Runner.run instead of Runner.new.run"
12
12
  )
13
13
  self
14
14
  end
@@ -57,7 +57,7 @@ module MaintenanceTasks
57
57
  def enqueue(run)
58
58
  unless MaintenanceTasks.job.constantize.perform_later(run)
59
59
  raise "The job to perform #{run.task_name} could not be enqueued. "\
60
- 'Enqueuing has been prevented by a callback.'
60
+ "Enqueuing has been prevented by a callback."
61
61
  end
62
62
  rescue => error
63
63
  run.persist_error(error)
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # This class is responsible for handling cursor-based pagination for Run
4
+ # records.
5
+ #
6
+ # @api private
7
+ class RunsPage
8
+ # The number of Runs to show on a single Task page.
9
+ RUNS_PER_PAGE = 20
10
+
11
+ # Initializes a Runs Page with a Runs relation and a cursor. This page is
12
+ # used by the views to render a set of Runs.
13
+ # @param runs [ActiveRecord::Relation<MaintenanceTasks::Run>] the relation
14
+ # of Run records to be paginated.
15
+ # @param cursor [String, nil] the id that serves as the cursor when
16
+ # querying the Runs dataset to produce a page of Runs. If nil, the first
17
+ # Runs in the relation are used.
18
+ def initialize(runs, cursor)
19
+ @runs = runs
20
+ @cursor = cursor
21
+ end
22
+
23
+ # Returns the records for a Page, taking into account the cursor if one is
24
+ # present. Limits the number of records to 20.
25
+ #
26
+ # @return [ActiveRecord::Relation<MaintenanceTasks::Run>] a limited amount
27
+ # of Run records.
28
+ def records
29
+ @records ||= begin
30
+ runs_after_cursor = if @cursor.present?
31
+ @runs.where("id < ?", @cursor)
32
+ else
33
+ @runs
34
+ end
35
+ runs_after_cursor.limit(RUNS_PER_PAGE)
36
+ end
37
+ end
38
+
39
+ # Returns the cursor to use for the next Page of Runs. It is the id of the
40
+ # last record on the current Page.
41
+ #
42
+ # @return [Integer] the id of the last record for the Page.
43
+ def next_cursor
44
+ records.last.id
45
+ end
46
+
47
+ # Returns whether this Page is the last one.
48
+ #
49
+ # @return [Boolean] whether this Page contains the last Run record in the
50
+ # Runs dataset that is being paginated.
51
+ def last?
52
+ @runs.unscope(:includes).pluck(:id).last == next_cursor
53
+ end
54
+ end
55
+ end
@@ -41,7 +41,7 @@ module MaintenanceTasks
41
41
  task_names = Task.available_tasks.map(&:name)
42
42
  available_task_runs = Run.where(task_name: task_names)
43
43
  last_runs = Run.where(
44
- id: available_task_runs.select('MAX(id) as id').group(:task_name)
44
+ id: available_task_runs.select("MAX(id) as id").group(:task_name)
45
45
  )
46
46
 
47
47
  task_names.map do |task_name|
@@ -111,7 +111,7 @@ module MaintenanceTasks
111
111
  #
112
112
  # @return [String] the Task status.
113
113
  def status
114
- last_run&.status || 'new'
114
+ last_run&.status || "new"
115
115
  end
116
116
 
117
117
  # Retrieves the Task's category, which is one of active, new, or completed.
@@ -135,7 +135,7 @@ module MaintenanceTasks
135
135
  private
136
136
 
137
137
  def runs
138
- Run.where(task_name: name).with_attached_csv_file.order(created_at: :desc)
138
+ Run.where(task_name: name).with_attached_csv.order(created_at: :desc)
139
139
  end
140
140
  end
141
141
  end
@@ -1,5 +1,4 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  module MaintenanceTasks
4
3
  # Base class that is inherited by the host application's task classes.
5
4
  class Task
@@ -7,6 +6,13 @@ module MaintenanceTasks
7
6
 
8
7
  class NotFoundError < NameError; end
9
8
 
9
+ # The throttle conditions for a given Task. This is provided as an array of
10
+ # hashes, with each hash specifying two keys: throttle_condition and
11
+ # backoff. Note that Tasks inherit conditions from their superclasses.
12
+ #
13
+ # @api private
14
+ class_attribute :throttle_conditions, default: []
15
+
10
16
  class << self
11
17
  # Finds a Task with the given name.
12
18
  #
@@ -38,6 +44,10 @@ module MaintenanceTasks
38
44
  # An input to upload a CSV will be added in the form to start a Run. The
39
45
  # collection and count method are implemented.
40
46
  def csv_collection
47
+ if !defined?(ActiveStorage) || !ActiveStorage::Attachment.table_exists?
48
+ raise NotImplementedError, "Active Storage needs to be installed\n"\
49
+ "To resolve this issue run: bin/rails active_storage:install"
50
+ end
41
51
  include(CsvCollection)
42
52
  end
43
53
 
@@ -68,6 +78,19 @@ module MaintenanceTasks
68
78
  new.count
69
79
  end
70
80
 
81
+ # Add a condition under which this Task will be throttled.
82
+ #
83
+ # @param backoff [ActiveSupport::Duration] optionally, a custom backoff
84
+ # can be specified. This is the time to wait before retrying the Task.
85
+ # If no value is specified, it defaults to 30 seconds.
86
+ # @yieldreturn [Boolean] where the throttle condition is being met,
87
+ # indicating that the Task should throttle.
88
+ def throttle_on(backoff: 30.seconds, &condition)
89
+ self.throttle_conditions += [
90
+ { throttle_on: condition, backoff: backoff },
91
+ ]
92
+ end
93
+
71
94
  private
72
95
 
73
96
  def load_constants
@@ -13,7 +13,7 @@ module MaintenanceTasks
13
13
  # before starting.
14
14
  # enqueued -> errored occurs when the task job fails to be enqueued, or
15
15
  # if the Task is deleted before is starts running.
16
- 'enqueued' => ['running', 'pausing', 'cancelling', 'errored'],
16
+ "enqueued" => ["running", "pausing", "cancelling", "errored"],
17
17
  # pausing -> paused occurs when the task actually halts performing and
18
18
  # occupies a status of paused.
19
19
  # pausing -> cancelling occurs when the user cancels a task immediately
@@ -24,14 +24,14 @@ module MaintenanceTasks
24
24
  # nothing in its collection to process.
25
25
  # pausing -> errored occurs when the job raises an exception after the
26
26
  # user has paused it.
27
- 'pausing' => ['paused', 'cancelling', 'succeeded', 'errored'],
27
+ "pausing" => ["paused", "cancelling", "succeeded", "errored"],
28
28
  # cancelling -> cancelled occurs when the task actually halts performing
29
29
  # and occupies a status of cancelled.
30
30
  # cancelling -> succeeded occurs when the task completes immediately after
31
31
  # being cancelled. See description for pausing -> succeeded.
32
32
  # cancelling -> errored occurs when the job raises an exception after the
33
33
  # user has cancelled it.
34
- 'cancelling' => ['cancelled', 'succeeded', 'errored'],
34
+ "cancelling" => ["cancelled", "succeeded", "errored"],
35
35
  # running -> succeeded occurs when the task completes successfully.
36
36
  # running -> pausing occurs when a user pauses the task as
37
37
  # it's performing.
@@ -40,17 +40,17 @@ module MaintenanceTasks
40
40
  # running -> interrupted occurs when the job infra shuts down the task as
41
41
  # it's performing.
42
42
  # running -> errored occurs when the job raises an exception when running.
43
- 'running' => [
44
- 'succeeded',
45
- 'pausing',
46
- 'cancelling',
47
- 'interrupted',
48
- 'errored',
43
+ "running" => [
44
+ "succeeded",
45
+ "pausing",
46
+ "cancelling",
47
+ "interrupted",
48
+ "errored",
49
49
  ],
50
50
  # paused -> enqueued occurs when the task is resumed after being paused.
51
51
  # paused -> cancelling when the user cancels the task after it is paused.
52
52
  # paused -> cancelled when the user cancels the task after it is paused.
53
- 'paused' => ['enqueued', 'cancelling', 'cancelled'],
53
+ "paused" => ["enqueued", "cancelling", "cancelled"],
54
54
  # interrupted -> running occurs when the task is resumed after being
55
55
  # interrupted by the job infrastructure.
56
56
  # interrupted -> pausing occurs when the task is paused by the user while
@@ -59,7 +59,7 @@ module MaintenanceTasks
59
59
  # while it is interrupted.
60
60
  # interrupted -> errored occurs when the task is deleted while it is
61
61
  # interrupted.
62
- 'interrupted' => ['running', 'pausing', 'cancelling', 'errored'],
62
+ "interrupted" => ["running", "pausing", "cancelling", "errored"],
63
63
  }
64
64
 
65
65
  # Validate whether a transition from one Run status