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.
- checksums.yaml +4 -4
- data/README.md +180 -22
- data/Rakefile +16 -16
- data/app/controllers/maintenance_tasks/application_controller.rb +3 -6
- data/app/controllers/maintenance_tasks/runs_controller.rb +1 -12
- data/app/controllers/maintenance_tasks/tasks_controller.rb +7 -4
- data/app/helpers/maintenance_tasks/application_helper.rb +2 -48
- data/app/helpers/maintenance_tasks/{task_helper.rb → tasks_helper.rb} +26 -18
- data/app/jobs/maintenance_tasks/task_job.rb +49 -6
- data/app/models/maintenance_tasks/application_record.rb +2 -2
- data/app/models/maintenance_tasks/csv_collection.rb +33 -0
- data/app/models/maintenance_tasks/progress.rb +19 -14
- data/app/models/maintenance_tasks/run.rb +39 -1
- data/app/models/maintenance_tasks/runner.rb +20 -5
- data/app/models/maintenance_tasks/runs_page.rb +55 -0
- data/app/models/maintenance_tasks/task_data.rb +9 -5
- data/app/models/maintenance_tasks/ticker.rb +0 -1
- data/app/tasks/maintenance_tasks/task.rb +43 -16
- data/app/validators/maintenance_tasks/run_status_validator.rb +15 -13
- data/app/views/layouts/maintenance_tasks/_navbar.html.erb +0 -6
- data/app/views/maintenance_tasks/runs/_info.html.erb +6 -0
- data/app/views/maintenance_tasks/runs/_run.html.erb +1 -9
- data/app/views/maintenance_tasks/runs/info/_running.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/show.html.erb +31 -4
- data/config/routes.rb +4 -6
- data/db/migrate/20210225152418_remove_index_on_task_name.rb +14 -0
- data/exe/maintenance_tasks +2 -2
- data/lib/generators/maintenance_tasks/install_generator.rb +4 -5
- data/lib/generators/maintenance_tasks/task_generator.rb +15 -9
- data/lib/generators/maintenance_tasks/templates/csv_task.rb.tt +13 -0
- data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +1 -1
- data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +1 -1
- data/lib/maintenance_tasks.rb +34 -30
- data/lib/maintenance_tasks/cli.rb +18 -4
- data/lib/maintenance_tasks/engine.rb +6 -3
- metadata +9 -31
- data/app/views/maintenance_tasks/runs/index.html.erb +0 -15
- data/app/views/maintenance_tasks/tasks/actions/_cancelled.html.erb +0 -1
- data/app/views/maintenance_tasks/tasks/actions/_cancelling.html.erb +0 -4
- data/app/views/maintenance_tasks/tasks/actions/_enqueued.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/actions/_errored.html.erb +0 -1
- data/app/views/maintenance_tasks/tasks/actions/_interrupted.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/actions/_new.html.erb +0 -1
- data/app/views/maintenance_tasks/tasks/actions/_paused.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/actions/_pausing.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/actions/_running.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/actions/_succeeded.html.erb +0 -1
- data/lib/maintenance_tasks/integrations/bugsnag_handler.rb +0 -4
@@ -1,23 +1,23 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require
|
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
|
9
|
+
module TasksHelper
|
10
10
|
STATUS_COLOURS = {
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
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
|
-
|
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: [
|
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(
|
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,
|
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
|
-
|
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
|
-
|
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
|
-
|
95
|
-
|
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
|
-
#
|
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
|
45
|
-
#
|
46
|
-
#
|
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
|
49
|
-
def
|
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 #{
|
52
|
-
elsif
|
53
|
-
"Processed #{
|
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 *
|
58
|
+
percentage = 100.0 * count / total
|
57
59
|
|
58
|
-
"Processed #{
|
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? &&
|
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
|
-
|
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
|
-
|
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
|