maintenance_tasks 1.0.0 → 1.2.1

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +180 -22
  3. data/Rakefile +16 -16
  4. data/app/controllers/maintenance_tasks/application_controller.rb +3 -6
  5. data/app/controllers/maintenance_tasks/runs_controller.rb +1 -12
  6. data/app/controllers/maintenance_tasks/tasks_controller.rb +7 -4
  7. data/app/helpers/maintenance_tasks/application_helper.rb +2 -48
  8. data/app/helpers/maintenance_tasks/{task_helper.rb → tasks_helper.rb} +26 -18
  9. data/app/jobs/maintenance_tasks/task_job.rb +49 -6
  10. data/app/models/maintenance_tasks/application_record.rb +2 -2
  11. data/app/models/maintenance_tasks/csv_collection.rb +33 -0
  12. data/app/models/maintenance_tasks/progress.rb +19 -14
  13. data/app/models/maintenance_tasks/run.rb +39 -1
  14. data/app/models/maintenance_tasks/runner.rb +20 -5
  15. data/app/models/maintenance_tasks/runs_page.rb +55 -0
  16. data/app/models/maintenance_tasks/task_data.rb +9 -5
  17. data/app/models/maintenance_tasks/ticker.rb +0 -1
  18. data/app/tasks/maintenance_tasks/task.rb +43 -16
  19. data/app/validators/maintenance_tasks/run_status_validator.rb +15 -13
  20. data/app/views/layouts/maintenance_tasks/_navbar.html.erb +0 -6
  21. data/app/views/maintenance_tasks/runs/_info.html.erb +6 -0
  22. data/app/views/maintenance_tasks/runs/_run.html.erb +1 -9
  23. data/app/views/maintenance_tasks/runs/info/_running.html.erb +0 -2
  24. data/app/views/maintenance_tasks/tasks/show.html.erb +31 -4
  25. data/config/routes.rb +4 -6
  26. data/db/migrate/20210225152418_remove_index_on_task_name.rb +14 -0
  27. data/exe/maintenance_tasks +2 -2
  28. data/lib/generators/maintenance_tasks/install_generator.rb +4 -5
  29. data/lib/generators/maintenance_tasks/task_generator.rb +15 -9
  30. data/lib/generators/maintenance_tasks/templates/csv_task.rb.tt +13 -0
  31. data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +1 -1
  32. data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +1 -1
  33. data/lib/maintenance_tasks.rb +34 -30
  34. data/lib/maintenance_tasks/cli.rb +18 -4
  35. data/lib/maintenance_tasks/engine.rb +6 -3
  36. metadata +9 -31
  37. data/app/views/maintenance_tasks/runs/index.html.erb +0 -15
  38. data/app/views/maintenance_tasks/tasks/actions/_cancelled.html.erb +0 -1
  39. data/app/views/maintenance_tasks/tasks/actions/_cancelling.html.erb +0 -4
  40. data/app/views/maintenance_tasks/tasks/actions/_enqueued.html.erb +0 -2
  41. data/app/views/maintenance_tasks/tasks/actions/_errored.html.erb +0 -1
  42. data/app/views/maintenance_tasks/tasks/actions/_interrupted.html.erb +0 -2
  43. data/app/views/maintenance_tasks/tasks/actions/_new.html.erb +0 -1
  44. data/app/views/maintenance_tasks/tasks/actions/_paused.html.erb +0 -2
  45. data/app/views/maintenance_tasks/tasks/actions/_pausing.html.erb +0 -2
  46. data/app/views/maintenance_tasks/tasks/actions/_running.html.erb +0 -2
  47. data/app/views/maintenance_tasks/tasks/actions/_succeeded.html.erb +0 -1
  48. data/lib/maintenance_tasks/integrations/bugsnag_handler.rb +0 -4
@@ -1,23 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ripper'
3
+ require "ripper"
4
4
 
5
5
  module MaintenanceTasks
6
6
  # Helpers for formatting data in the maintenance_tasks views.
7
7
  #
8
8
  # @api private
9
- module TaskHelper
9
+ module TasksHelper
10
10
  STATUS_COLOURS = {
11
- 'new' => ['is-primary'],
12
- 'enqueued' => ['is-primary is-light'],
13
- 'running' => ['is-info'],
14
- 'interrupted' => ['is-info', 'is-light'],
15
- 'pausing' => ['is-warning', 'is-light'],
16
- 'paused' => ['is-warning'],
17
- 'succeeded' => ['is-success'],
18
- 'cancelling' => ['is-light'],
19
- 'cancelled' => ['is-dark'],
20
- 'errored' => ['is-danger'],
11
+ "new" => ["is-primary"],
12
+ "enqueued" => ["is-primary is-light"],
13
+ "running" => ["is-info"],
14
+ "interrupted" => ["is-info", "is-light"],
15
+ "pausing" => ["is-warning", "is-light"],
16
+ "paused" => ["is-warning"],
17
+ "succeeded" => ["is-success"],
18
+ "cancelling" => ["is-light"],
19
+ "cancelled" => ["is-dark"],
20
+ "errored" => ["is-danger"],
21
21
  }
22
22
 
23
23
  # Formats a run backtrace.
@@ -44,12 +44,13 @@ module MaintenanceTasks
44
44
 
45
45
  progress = Progress.new(run)
46
46
 
47
- tag.progress(
47
+ progress_bar = tag.progress(
48
48
  value: progress.value,
49
49
  max: progress.max,
50
- title: progress.title,
51
- class: ['progress'] + STATUS_COLOURS.fetch(run.status)
50
+ class: ["progress"] + STATUS_COLOURS.fetch(run.status)
52
51
  )
52
+ progress_text = tag.p(tag.i(progress.text))
53
+ tag.div(progress_bar + progress_text, class: "block")
53
54
  end
54
55
 
55
56
  # Renders a span with a Run's status, with the corresponding tag class
@@ -59,7 +60,7 @@ module MaintenanceTasks
59
60
  # @return [String] the span element containing the status, with the
60
61
  # appropriate tag class attached.
61
62
  def status_tag(status)
62
- tag.span(status.capitalize, class: ['tag'] + STATUS_COLOURS.fetch(status))
63
+ tag.span(status.capitalize, class: ["tag"] + STATUS_COLOURS.fetch(status))
63
64
  end
64
65
 
65
66
  # Returns the distance between now and the Run's expected completion time,
@@ -100,11 +101,18 @@ module MaintenanceTasks
100
101
  when :on_nl, :on_sp, :on_ignored_nl
101
102
  content
102
103
  else
103
- tag.span(content, class: type.to_s.sub('on_', 'ruby-').dasherize)
104
+ tag.span(content, class: type.to_s.sub("on_", "ruby-").dasherize)
104
105
  end
105
106
  end
106
107
  safe_join(tokens)
107
108
  end
109
+
110
+ # Returns a download link for a Run's CSV attachment
111
+ def csv_file_download_path(run)
112
+ Rails.application.routes.url_helpers.rails_blob_path(
113
+ run.csv_file,
114
+ only_path: true
115
+ )
116
+ end
108
117
  end
109
- private_constant :TaskHelper
110
118
  end
@@ -19,7 +19,7 @@ module MaintenanceTasks
19
19
  # Overrides ActiveJob::Exceptions.retry_on to declare it unsupported.
20
20
  # The use of rescue_from prevents retry_on from being usable.
21
21
  def retry_on(*, **)
22
- raise NotImplementedError, 'retry_on is not supported'
22
+ raise NotImplementedError, "retry_on is not supported"
23
23
  end
24
24
  end
25
25
 
@@ -34,9 +34,11 @@ module MaintenanceTasks
34
34
  enumerator_builder.active_record_on_records(collection, cursor: cursor)
35
35
  when Array
36
36
  enumerator_builder.build_array_enumerator(collection, cursor: cursor)
37
+ when CSV
38
+ JobIteration::CsvEnumerator.new(collection).rows(cursor: cursor)
37
39
  else
38
40
  raise ArgumentError, "#{@task.class.name}#collection must be either "\
39
- 'an Active Record Relation or an Array.'
41
+ "an Active Record Relation, Array, or CSV."
40
42
  end
41
43
  end
42
44
 
@@ -47,14 +49,24 @@ module MaintenanceTasks
47
49
  # @param _run [Run] the current Run, passed as an argument by Job Iteration.
48
50
  def each_iteration(input, _run)
49
51
  throw(:abort, :skip_complete_callbacks) if @run.stopping?
50
- @task.process(input)
52
+ task_iteration(input)
51
53
  @ticker.tick
52
54
  @run.reload_status
53
55
  end
54
56
 
57
+ def task_iteration(input)
58
+ @task.process(input)
59
+ rescue => error
60
+ @errored_element = input
61
+ raise error
62
+ end
63
+
55
64
  def before_perform
56
65
  @run = arguments.first
57
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
58
70
  @run.job_id = job_id
59
71
 
60
72
  @run.running! unless @run.stopping?
@@ -85,14 +97,45 @@ module MaintenanceTasks
85
97
  @ticker.persist
86
98
  end
87
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
+
88
116
  def after_perform
89
117
  @run.save!
118
+ if defined?(@reenqueue_iteration_job) && @reenqueue_iteration_job
119
+ reenqueue_iteration_job(should_ignore: false)
120
+ end
90
121
  end
91
122
 
92
123
  def on_error(error)
93
- @ticker.persist
94
- @run.persist_error(error)
95
- MaintenanceTasks.error_handler.call(error)
124
+ @ticker.persist if defined?(@ticker)
125
+
126
+ if defined?(@run)
127
+ @run.persist_error(error)
128
+
129
+ task_context = {
130
+ task_name: @run.task_name,
131
+ started_at: @run.started_at,
132
+ ended_at: @run.ended_at,
133
+ }
134
+ else
135
+ task_context = {}
136
+ end
137
+ errored_element = @errored_element if defined?(@errored_element)
138
+ MaintenanceTasks.error_handler.call(error, task_context, errored_element)
96
139
  end
97
140
  end
98
141
  end
@@ -2,9 +2,9 @@
2
2
  module MaintenanceTasks
3
3
  # Base class for all records used by this engine.
4
4
  #
5
- # @api private
5
+ # Can be extended to setup different database where all tables related to
6
+ # maintenance tasks will live.
6
7
  class ApplicationRecord < ActiveRecord::Base
7
8
  self.abstract_class = true
8
9
  end
9
- private_constant :ApplicationRecord
10
10
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module MaintenanceTasks
6
+ # Module that is included into Task classes by Task.csv_collection for
7
+ # processing CSV files.
8
+ #
9
+ # @api private
10
+ module CsvCollection
11
+ # The contents of a CSV file to be processed by a Task.
12
+ #
13
+ # @return [String] the content of the CSV file to process.
14
+ attr_accessor :csv_content
15
+
16
+ # Defines the collection to be iterated over, based on the provided CSV.
17
+ #
18
+ # @return [CSV] the CSV object constructed from the specified CSV content,
19
+ # with headers.
20
+ def collection
21
+ CSV.new(csv_content, headers: true)
22
+ end
23
+
24
+ # The number of rows to be processed. Excludes the header row from the count
25
+ # and assumed a trailing new line in the CSV file. Note that this number is
26
+ # an approximation based on the number of new lines.
27
+ #
28
+ # @return [Integer] the approximate number of rows to process.
29
+ def count
30
+ csv_content.count("\n") - 1
31
+ end
32
+ end
33
+ end
@@ -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)}."
52
- elsif @run.tick_count > @run.tick_total
53
- "Processed #{@run.tick_count} #{'item'.pluralize(@run.tick_count)} " \
54
- "(expected #{@run.tick_total})."
54
+ "Processed #{pluralize(count, "item")}."
55
+ elsif over_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
 
@@ -67,8 +69,11 @@ module MaintenanceTasks
67
69
  end
68
70
 
69
71
  def estimatable?
70
- total? && @run.tick_total > @run.tick_count
72
+ total? && !over_total?
73
+ end
74
+
75
+ def over_total?
76
+ @run.tick_count > @run.tick_total
71
77
  end
72
78
  end
73
- private_constant :Progress
74
79
  end
@@ -33,14 +33,28 @@ module MaintenanceTasks
33
33
  validates :task_name, on: :create, inclusion: { in: ->(_) {
34
34
  Task.available_tasks.map(&:to_s)
35
35
  } }
36
+ validate :csv_attachment_presence, on: :create
37
+
36
38
  attr_readonly :task_name
37
39
 
38
40
  serialize :backtrace
39
41
 
40
42
  scope :active, -> { where(status: ACTIVE_STATUSES) }
41
43
 
44
+ # Ensure ActiveStorage is in use before preloading the attachments
45
+ scope :with_attached_csv, -> do
46
+ with_attached_csv_file if ActiveStorage::Attachment.table_exists?
47
+ end
48
+
42
49
  validates_with RunStatusValidator, on: :update
43
50
 
51
+ if MaintenanceTasks.active_storage_service.present?
52
+ has_one_attached :csv_file,
53
+ service: MaintenanceTasks.active_storage_service
54
+ else
55
+ has_one_attached :csv_file
56
+ end
57
+
44
58
  # Sets the run status to enqueued, making sure the transition is validated
45
59
  # in case it's already enqueued.
46
60
  def enqueued!
@@ -175,6 +189,30 @@ module MaintenanceTasks
175
189
  def stuck?
176
190
  cancelling? && updated_at <= 5.minutes.ago
177
191
  end
192
+
193
+ # Performs validation on the presence of a :csv_file attachment.
194
+ # A Run for a Task that uses CsvCollection must have an attached :csv_file
195
+ # to be valid. Conversely, a Run for a Task that doesn't use CsvCollection
196
+ # should not have an attachment to be valid. The appropriate error is added
197
+ # if the Run does not meet the above criteria.
198
+ def csv_attachment_presence
199
+ if Task.named(task_name) < CsvCollection && !csv_file.attached?
200
+ errors.add(:csv_file, "must be attached to CSV Task.")
201
+ elsif !(Task.named(task_name) < CsvCollection) && csv_file.present?
202
+ errors.add(:csv_file, "should not be attached to non-CSV Task.")
203
+ end
204
+ rescue Task::NotFoundError
205
+ nil
206
+ end
207
+
208
+ # Fetches the attached ActiveStorage CSV file for the run. Checks first
209
+ # whether the ActiveStorage::Attachment table exists so that we are
210
+ # compatible with apps that are not using ActiveStorage.
211
+ #
212
+ # @return [ActiveStorage::Attached::One] the attached CSV file
213
+ def csv_file
214
+ return unless ActiveStorage::Attachment.table_exists?
215
+ super
216
+ end
178
217
  end
179
- private_constant :Run
180
218
  end
@@ -2,7 +2,17 @@
2
2
 
3
3
  module MaintenanceTasks
4
4
  # This class is responsible for running a given Task.
5
- class Runner
5
+ module Runner
6
+ extend self
7
+
8
+ # @deprecated Use {Runner} directly instead.
9
+ def new
10
+ ActiveSupport::Deprecation.warn(
11
+ "Use Runner.run instead of Runner.new.run"
12
+ )
13
+ self
14
+ end
15
+
6
16
  # Exception raised when a Task Job couldn't be enqueued.
7
17
  class EnqueuingError < StandardError
8
18
  # Initializes a Enqueuing Error.
@@ -20,17 +30,22 @@ module MaintenanceTasks
20
30
  # Runs a Task.
21
31
  #
22
32
  # This method creates a Run record for the given Task name and enqueues the
23
- # Run.
33
+ # Run. If a CSV file is provided, it is attached to the Run record.
24
34
  #
25
35
  # @param name [String] the name of the Task to be run.
36
+ # @param csv_file [attachable, nil] a CSV file that provides the collection
37
+ # for the Task to iterate over when running, in the form of an attachable
38
+ # (see https://edgeapi.rubyonrails.org/classes/ActiveStorage/Attached/One.html#method-i-attach).
39
+ # Value is nil if the Task does not use CSV iteration.
26
40
  #
27
41
  # @return [Task] the Task that was run.
28
42
  #
29
43
  # @raise [EnqueuingError] if an error occurs while enqueuing the Run.
30
44
  # @raise [ActiveRecord::RecordInvalid] if validation errors occur while
31
45
  # creating the Run.
32
- def run(name:)
46
+ def run(name:, csv_file: nil)
33
47
  run = Run.active.find_by(task_name: name) || Run.new(task_name: name)
48
+ run.csv_file.attach(csv_file) if csv_file
34
49
 
35
50
  run.enqueued!
36
51
  enqueue(run)
@@ -40,9 +55,9 @@ module MaintenanceTasks
40
55
  private
41
56
 
42
57
  def enqueue(run)
43
- unless MaintenanceTasks.job.perform_later(run)
58
+ unless MaintenanceTasks.job.constantize.perform_later(run)
44
59
  raise "The job to perform #{run.task_name} could not be enqueued. "\
45
- 'Enqueuing has been prevented by a callback.'
60
+ "Enqueuing has been prevented by a callback."
46
61
  end
47
62
  rescue => error
48
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