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.
- checksums.yaml +4 -4
- data/LICENSE.md +18 -0
- data/README.md +90 -32
- data/app/controllers/maintenance_tasks/application_controller.rb +2 -4
- data/app/controllers/maintenance_tasks/tasks_controller.rb +1 -1
- data/app/helpers/maintenance_tasks/application_helper.rb +2 -13
- data/app/helpers/maintenance_tasks/tasks_helper.rb +17 -16
- data/app/jobs/concerns/maintenance_tasks/task_job_concern.rb +150 -0
- data/app/jobs/maintenance_tasks/task_job.rb +1 -129
- data/app/models/maintenance_tasks/csv_collection.rb +1 -1
- data/app/models/maintenance_tasks/progress.rb +13 -11
- data/app/models/maintenance_tasks/run.rb +26 -4
- data/app/models/maintenance_tasks/runner.rb +2 -2
- data/app/models/maintenance_tasks/runs_page.rb +55 -0
- data/app/models/maintenance_tasks/task_data.rb +3 -3
- data/app/tasks/maintenance_tasks/task.rb +24 -1
- data/app/validators/maintenance_tasks/run_status_validator.rb +11 -11
- data/app/views/maintenance_tasks/runs/_info.html.erb +1 -1
- data/app/views/maintenance_tasks/runs/info/_running.html.erb +0 -2
- data/app/views/maintenance_tasks/tasks/show.html.erb +3 -3
- data/config/routes.rb +4 -4
- data/db/migrate/20210225152418_remove_index_on_task_name.rb +1 -1
- data/exe/maintenance_tasks +2 -2
- data/lib/generators/maintenance_tasks/install_generator.rb +4 -4
- data/lib/generators/maintenance_tasks/task_generator.rb +10 -10
- data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +7 -5
- data/lib/maintenance_tasks.rb +51 -29
- data/lib/maintenance_tasks/cli.rb +3 -3
- data/lib/maintenance_tasks/engine.rb +4 -3
- metadata +10 -21
- 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
|
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
|
@@ -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 #{
|
54
|
+
"Processed #{pluralize(count, "item")}."
|
52
55
|
elsif over_total?
|
53
|
-
"Processed #{
|
54
|
-
"(expected #{@run.tick_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
|
|
@@ -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
|
-
|
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,
|
191
|
-
elsif !(Task.named(task_name) < CsvCollection) && csv_file.
|
192
|
-
errors.add(:csv_file,
|
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
|
-
|
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
|
-
|
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(
|
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 ||
|
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).
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
-
|
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
|
-
|
62
|
+
"interrupted" => ["running", "pausing", "cancelling", "errored"],
|
63
63
|
}
|
64
64
|
|
65
65
|
# Validate whether a transition from one Run status
|