maintenance_tasks 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +316 -0
  3. data/Rakefile +29 -0
  4. data/app/controllers/maintenance_tasks/application_controller.rb +26 -0
  5. data/app/controllers/maintenance_tasks/runs_controller.rb +44 -0
  6. data/app/controllers/maintenance_tasks/tasks_controller.rb +42 -0
  7. data/app/helpers/maintenance_tasks/application_helper.rb +67 -0
  8. data/app/helpers/maintenance_tasks/task_helper.rb +110 -0
  9. data/app/jobs/maintenance_tasks/task_job.rb +98 -0
  10. data/app/models/maintenance_tasks/application_record.rb +10 -0
  11. data/app/models/maintenance_tasks/progress.rb +74 -0
  12. data/app/models/maintenance_tasks/run.rb +180 -0
  13. data/app/models/maintenance_tasks/runner.rb +52 -0
  14. data/app/models/maintenance_tasks/task_data.rb +137 -0
  15. data/app/models/maintenance_tasks/ticker.rb +58 -0
  16. data/app/tasks/maintenance_tasks/task.rb +83 -0
  17. data/app/validators/maintenance_tasks/run_status_validator.rb +86 -0
  18. data/app/views/layouts/maintenance_tasks/_navbar.html.erb +11 -0
  19. data/app/views/layouts/maintenance_tasks/application.html.erb +54 -0
  20. data/app/views/maintenance_tasks/runs/_info.html.erb +10 -0
  21. data/app/views/maintenance_tasks/runs/_run.html.erb +11 -0
  22. data/app/views/maintenance_tasks/runs/index.html.erb +15 -0
  23. data/app/views/maintenance_tasks/runs/info/_cancelled.html.erb +4 -0
  24. data/app/views/maintenance_tasks/runs/info/_cancelling.html.erb +1 -0
  25. data/app/views/maintenance_tasks/runs/info/_enqueued.html.erb +1 -0
  26. data/app/views/maintenance_tasks/runs/info/_errored.html.erb +25 -0
  27. data/app/views/maintenance_tasks/runs/info/_interrupted.html.erb +1 -0
  28. data/app/views/maintenance_tasks/runs/info/_paused.html.erb +8 -0
  29. data/app/views/maintenance_tasks/runs/info/_pausing.html.erb +1 -0
  30. data/app/views/maintenance_tasks/runs/info/_running.html.erb +7 -0
  31. data/app/views/maintenance_tasks/runs/info/_succeeded.html.erb +5 -0
  32. data/app/views/maintenance_tasks/tasks/_task.html.erb +8 -0
  33. data/app/views/maintenance_tasks/tasks/actions/_cancelled.html.erb +1 -0
  34. data/app/views/maintenance_tasks/tasks/actions/_cancelling.html.erb +4 -0
  35. data/app/views/maintenance_tasks/tasks/actions/_enqueued.html.erb +2 -0
  36. data/app/views/maintenance_tasks/tasks/actions/_errored.html.erb +1 -0
  37. data/app/views/maintenance_tasks/tasks/actions/_interrupted.html.erb +2 -0
  38. data/app/views/maintenance_tasks/tasks/actions/_new.html.erb +1 -0
  39. data/app/views/maintenance_tasks/tasks/actions/_paused.html.erb +2 -0
  40. data/app/views/maintenance_tasks/tasks/actions/_pausing.html.erb +2 -0
  41. data/app/views/maintenance_tasks/tasks/actions/_running.html.erb +2 -0
  42. data/app/views/maintenance_tasks/tasks/actions/_succeeded.html.erb +1 -0
  43. data/app/views/maintenance_tasks/tasks/index.html.erb +22 -0
  44. data/app/views/maintenance_tasks/tasks/show.html.erb +25 -0
  45. data/config/routes.rb +19 -0
  46. data/db/migrate/20201211151756_create_maintenance_tasks_runs.rb +22 -0
  47. data/exe/maintenance_tasks +13 -0
  48. data/lib/generators/maintenance_tasks/install_generator.rb +22 -0
  49. data/lib/generators/maintenance_tasks/task_generator.rb +74 -0
  50. data/lib/generators/maintenance_tasks/templates/task.rb.tt +21 -0
  51. data/lib/generators/maintenance_tasks/templates/task_spec.rb.tt +14 -0
  52. data/lib/generators/maintenance_tasks/templates/task_test.rb.tt +12 -0
  53. data/lib/maintenance_tasks.rb +58 -0
  54. data/lib/maintenance_tasks/cli.rb +40 -0
  55. data/lib/maintenance_tasks/engine.rb +28 -0
  56. data/lib/maintenance_tasks/integrations/bugsnag_handler.rb +4 -0
  57. data/lib/tasks/maintenance_tasks_tasks.rake +5 -0
  58. metadata +187 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Module for common view helpers.
4
+ #
5
+ # @api private
6
+ module ApplicationHelper
7
+ include Pagy::Frontend
8
+
9
+ # Renders pagination for the page, if there is more than one page present.
10
+ #
11
+ # @param pagy [Pagy] the pagy instance containing pagination details,
12
+ # including the number of pages the results are spread across.
13
+ # @return [String] the HTML to render for pagination.
14
+ def pagination(pagy)
15
+ raw(pagy_bulma_nav(pagy)) if pagy.pages > 1
16
+ end
17
+
18
+ # Renders a time element with the given datetime, worded as relative to the
19
+ # current time.
20
+ #
21
+ # The ISO 8601 version of the datetime is shown on hover
22
+ # via a title attribute.
23
+ #
24
+ # @param datetime [ActiveSupport::TimeWithZone] the time to be presented.
25
+ # @return [String] the HTML to render with the relative datetime in words.
26
+ def time_ago(datetime)
27
+ time_tag(datetime, title: datetime.utc.iso8601, class: 'is-clickable') do
28
+ time_ago_in_words(datetime) + ' ago'
29
+ end
30
+ end
31
+
32
+ # Fix stylesheet_link_tag to handle integrity when preloading.
33
+ # To be reverted once fixed upstream in Rails.
34
+ def stylesheet_link_tag(*sources)
35
+ return super unless respond_to?(:send_preload_links_header, true)
36
+ options = sources.extract_options!.stringify_keys
37
+ path_options = options.extract!('protocol', 'host', 'skip_pipeline')
38
+ .symbolize_keys
39
+ preload_links = []
40
+ crossorigin = options.delete('crossorigin')
41
+ crossorigin = 'anonymous' if crossorigin == true
42
+ nopush = options['nopush'].nil? ? true : options.delete('nopush')
43
+ integrity = options['integrity']
44
+
45
+ sources_tags = sources.uniq.map do |source|
46
+ href = path_to_stylesheet(source, path_options)
47
+ preload_link = "<#{href}>; rel=preload; as=style"
48
+ preload_link += "; crossorigin=#{crossorigin}" unless crossorigin.nil?
49
+ preload_link += "; integrity=#{integrity}" unless integrity.nil?
50
+ preload_link += '; nopush' if nopush
51
+ preload_links << preload_link
52
+ tag_options = {
53
+ 'rel' => 'stylesheet',
54
+ 'media' => 'screen',
55
+ 'crossorigin' => crossorigin,
56
+ 'href' => href,
57
+ }.merge!(options)
58
+ tag(:link, tag_options)
59
+ end
60
+
61
+ send_preload_links_header(preload_links)
62
+
63
+ safe_join(sources_tags)
64
+ end
65
+ end
66
+ private_constant :ApplicationHelper
67
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ripper'
4
+
5
+ module MaintenanceTasks
6
+ # Helpers for formatting data in the maintenance_tasks views.
7
+ #
8
+ # @api private
9
+ module TaskHelper
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'],
21
+ }
22
+
23
+ # Formats a run backtrace.
24
+ #
25
+ # @param backtrace [Array<String>] the backtrace associated with an
26
+ # exception on a Task that ran and raised.
27
+ # @return [String] the parsed, HTML formatted version of the backtrace.
28
+ def format_backtrace(backtrace)
29
+ safe_join(backtrace.to_a, tag.br)
30
+ end
31
+
32
+ # Renders the progress bar.
33
+ #
34
+ # The style of the progress tag depends on the Run status. It also renders
35
+ # an infinite progress when a Run is active but there is no total
36
+ # information to estimate completion.
37
+ #
38
+ # @param run [Run] the Run which the progress bar will be based on.
39
+ #
40
+ # @return [String] the progress information properly formatted.
41
+ # @return [nil] if the run has not started yet.
42
+ def progress(run)
43
+ return unless run.started?
44
+
45
+ progress = Progress.new(run)
46
+
47
+ tag.progress(
48
+ value: progress.value,
49
+ max: progress.max,
50
+ title: progress.title,
51
+ class: ['progress'] + STATUS_COLOURS.fetch(run.status)
52
+ )
53
+ end
54
+
55
+ # Renders a span with a Run's status, with the corresponding tag class
56
+ # attached.
57
+ #
58
+ # @param status [String] the status for the Run.
59
+ # @return [String] the span element containing the status, with the
60
+ # appropriate tag class attached.
61
+ def status_tag(status)
62
+ tag.span(status.capitalize, class: ['tag'] + STATUS_COLOURS.fetch(status))
63
+ end
64
+
65
+ # Returns the distance between now and the Run's expected completion time,
66
+ # if the Run has an estimated_completion_time.
67
+ #
68
+ # @param run [MaintenanceTasks::Run] the Run for which the estimated time to
69
+ # completion is being calculated.
70
+ # return [String, nil] the distance in words, or nil if the Run has no
71
+ # estimated completion time.
72
+ def estimated_time_to_completion(run)
73
+ estimated_completion_time = run.estimated_completion_time
74
+ if estimated_completion_time.present?
75
+ time_ago_in_words(estimated_completion_time)
76
+ end
77
+ end
78
+
79
+ # Reports the approximate elapsed time a Run has been processed so far based
80
+ # on the Run's time running attribute.
81
+ #
82
+ # @param run [Run] the source of the time to be reported.
83
+ #
84
+ # @return [String] the description of the time running attribute.
85
+ def time_running_in_words(run)
86
+ distance_of_time_in_words(0, run.time_running, include_seconds: true)
87
+ end
88
+
89
+ # Very simple syntax highlighter based on Ripper.
90
+ #
91
+ # It returns the same code except identifiers, keywords, etc. are wrapped
92
+ # in +<span>+ tags with CSS classes that match the types returned by
93
+ # Ripper.lex.
94
+ #
95
+ # @param code [String] the Ruby code source to syntax highlight.
96
+ # @return [ActiveSupport::SafeBuffer] HTML of the code.
97
+ def highlight_code(code)
98
+ tokens = Ripper.lex(code).map do |(_position, type, content, _state)|
99
+ case type
100
+ when :on_nl, :on_sp, :on_ignored_nl
101
+ content
102
+ else
103
+ tag.span(content, class: type.to_s.sub('on_', 'ruby-').dasherize)
104
+ end
105
+ end
106
+ safe_join(tokens)
107
+ end
108
+ end
109
+ private_constant :TaskHelper
110
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base class that is inherited by the host application's task classes.
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
+ else
38
+ raise ArgumentError, "#{@task.class.name}#collection must be either "\
39
+ 'an Active Record Relation or an Array.'
40
+ end
41
+ end
42
+
43
+ # Performs task iteration logic for the current input returned by the
44
+ # enumerator.
45
+ #
46
+ # @param input [Object] the current element from the enumerator.
47
+ # @param _run [Run] the current Run, passed as an argument by Job Iteration.
48
+ def each_iteration(input, _run)
49
+ throw(:abort, :skip_complete_callbacks) if @run.stopping?
50
+ @task.process(input)
51
+ @ticker.tick
52
+ @run.reload_status
53
+ end
54
+
55
+ def before_perform
56
+ @run = arguments.first
57
+ @task = Task.named(@run.task_name).new
58
+ @run.job_id = job_id
59
+
60
+ @run.running! unless @run.stopping?
61
+
62
+ @ticker = Ticker.new(MaintenanceTasks.ticker_delay) do |ticks, duration|
63
+ @run.persist_progress(ticks, duration)
64
+ end
65
+ end
66
+
67
+ def on_start
68
+ @run.update!(started_at: Time.now, tick_total: @task.count)
69
+ end
70
+
71
+ def on_complete
72
+ @run.status = :succeeded
73
+ @run.ended_at = Time.now
74
+ end
75
+
76
+ def on_shutdown
77
+ if @run.cancelling?
78
+ @run.status = :cancelled
79
+ @run.ended_at = Time.now
80
+ else
81
+ @run.status = @run.pausing? ? :paused : :interrupted
82
+ @run.cursor = cursor_position
83
+ end
84
+
85
+ @ticker.persist
86
+ end
87
+
88
+ def after_perform
89
+ @run.save!
90
+ end
91
+
92
+ def on_error(error)
93
+ @ticker.persist
94
+ @run.persist_error(error)
95
+ MaintenanceTasks.error_handler.call(error)
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Base class for all records used by this engine.
4
+ #
5
+ # @api private
6
+ class ApplicationRecord < ActiveRecord::Base
7
+ self.abstract_class = true
8
+ end
9
+ private_constant :ApplicationRecord
10
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # This class generates progress information about a Run.
5
+ class Progress
6
+ include ActiveSupport::NumberHelper
7
+
8
+ # Sets the Progress initial state with a Run.
9
+ #
10
+ # @param run [Run] the source of progress information.
11
+ def initialize(run)
12
+ @run = run
13
+ end
14
+
15
+ # Defines the value of progress information. This represents the amount that
16
+ # is already done out of the progress maximum.
17
+ #
18
+ # For indefinite-style progress information, value is nil. That highlights
19
+ # that a Run is in progress but it is not possible to estimate how close to
20
+ # completion it is.
21
+ #
22
+ # When a Run is stopped, the value is present even if there is no total.
23
+ # That represents a progress information that assumes that the current value
24
+ # is also equal to is max, showing a progress as completed.
25
+ #
26
+ # @return [Integer] if progress can be determined or the Run is stopped.
27
+ # @return [nil] if progress can't be determined and the Run isn't stopped.
28
+ def value
29
+ @run.tick_count if estimatable? || @run.stopped?
30
+ end
31
+
32
+ # The maximum amount of work expected to be done. This is extracted from the
33
+ # Run's tick total attribute when present, or it is equal to the Run's
34
+ # tick count.
35
+ #
36
+ # This amount is enqual to the Run's tick count if the tick count is greater
37
+ # than the tick total. This represents that the total was underestimated.
38
+ #
39
+ # @return [Integer] the progress maximum amount.
40
+ def max
41
+ estimatable? ? @run.tick_total : @run.tick_count
42
+ end
43
+
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.
47
+ #
48
+ # @return [String] the title for the Run progress.
49
+ def title
50
+ 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})."
55
+ else
56
+ percentage = 100.0 * @run.tick_count / @run.tick_total
57
+
58
+ "Processed #{@run.tick_count} out of #{@run.tick_total} "\
59
+ "(#{number_to_percentage(percentage, precision: 0)})"
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def total?
66
+ @run.tick_total.to_i > 0
67
+ end
68
+
69
+ def estimatable?
70
+ total? && @run.tick_total > @run.tick_count
71
+ end
72
+ end
73
+ private_constant :Progress
74
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Model that persists information related to a task being run from the UI.
4
+ #
5
+ # @api private
6
+ class Run < ApplicationRecord
7
+ # Various statuses a run can be in.
8
+ STATUSES = [
9
+ :enqueued, # The task has been enqueued by the user.
10
+ :running, # The task is being performed by a job worker.
11
+ :succeeded, # The task finished without error.
12
+ :cancelling, # The task has been told to cancel but is finishing work.
13
+ :cancelled, # The user explicitly halted the task's execution.
14
+ :interrupted, # The task was interrupted by the job infrastructure.
15
+ :pausing, # The task has been told to pause but is finishing work.
16
+ :paused, # The task was paused in the middle of the run by the user.
17
+ :errored, # The task code produced an unhandled exception.
18
+ ]
19
+
20
+ ACTIVE_STATUSES = [
21
+ :enqueued,
22
+ :running,
23
+ :paused,
24
+ :pausing,
25
+ :cancelling,
26
+ :interrupted,
27
+ ]
28
+ COMPLETED_STATUSES = [:succeeded, :errored, :cancelled]
29
+ COMPLETED_RUNS_LIMIT = 10
30
+
31
+ enum status: STATUSES.to_h { |status| [status, status.to_s] }
32
+
33
+ validates :task_name, on: :create, inclusion: { in: ->(_) {
34
+ Task.available_tasks.map(&:to_s)
35
+ } }
36
+ attr_readonly :task_name
37
+
38
+ serialize :backtrace
39
+
40
+ scope :active, -> { where(status: ACTIVE_STATUSES) }
41
+
42
+ validates_with RunStatusValidator, on: :update
43
+
44
+ # Sets the run status to enqueued, making sure the transition is validated
45
+ # in case it's already enqueued.
46
+ def enqueued!
47
+ status_will_change!
48
+ super
49
+ end
50
+
51
+ # Increments +tick_count+ by +number_of_ticks+ and +time_running+ by
52
+ # +duration+, both directly in the DB.
53
+ # The attribute values are not set in the current instance, you need
54
+ # to reload the record.
55
+ #
56
+ # @param number_of_ticks [Integer] number of ticks to add to tick_count.
57
+ # @param duration [Float] the time in seconds that elapsed since the last
58
+ # increment of ticks.
59
+ def persist_progress(number_of_ticks, duration)
60
+ self.class.update_counters(
61
+ id,
62
+ tick_count: number_of_ticks,
63
+ time_running: duration,
64
+ touch: true
65
+ )
66
+ end
67
+
68
+ # Marks the run as errored and persists the error data.
69
+ #
70
+ # @param error [StandardError] the Error being persisted.
71
+ def persist_error(error)
72
+ update!(
73
+ status: :errored,
74
+ error_class: error.class.to_s,
75
+ error_message: error.message,
76
+ backtrace: Rails.backtrace_cleaner.clean(error.backtrace),
77
+ ended_at: Time.now,
78
+ )
79
+ end
80
+
81
+ # Refreshes just the status attribute on the Active Record object, and
82
+ # ensures ActiveModel::Dirty does not mark the object as changed.
83
+ # This allows us to get the Run's most up-to-date status without needing
84
+ # to reload the entire record.
85
+ #
86
+ # @return [MaintenanceTasks::Run] the Run record with its updated status.
87
+ def reload_status
88
+ updated_status = Run.uncached do
89
+ Run.where(id: id).pluck(:status).first
90
+ end
91
+ self.status = updated_status
92
+ clear_attribute_changes([:status])
93
+ self
94
+ end
95
+
96
+ # Returns whether the Run is stopping, which is defined as
97
+ # having a status of pausing or cancelled.
98
+ #
99
+ # @return [Boolean] whether the Run is stopping.
100
+ def stopping?
101
+ pausing? || cancelling?
102
+ end
103
+
104
+ # Returns whether the Run is stopped, which is defined as having a status of
105
+ # paused, succeeded, cancelled, or errored.
106
+ #
107
+ # @return [Boolean] whether the Run is stopped.
108
+ def stopped?
109
+ completed? || paused?
110
+ end
111
+
112
+ # Returns whether the Run has been started, which is indicated by the
113
+ # started_at timestamp being present.
114
+ #
115
+ # @return [Boolean] whether the Run was started.
116
+ def started?
117
+ started_at.present?
118
+ end
119
+
120
+ # Returns whether the Run is completed, which is defined as
121
+ # having a status of succeeded, cancelled, or errored.
122
+ #
123
+ # @return [Boolean] whether the Run is completed.
124
+ def completed?
125
+ COMPLETED_STATUSES.include?(status.to_sym)
126
+ end
127
+
128
+ # Returns whether the Run is active, which is defined as
129
+ # having a status of enqueued, running, pausing, cancelling,
130
+ # paused or interrupted.
131
+ #
132
+ # @return [Boolean] whether the Run is active.
133
+ def active?
134
+ ACTIVE_STATUSES.include?(status.to_sym)
135
+ end
136
+
137
+ # Returns the estimated time the task will finish based on the the number of
138
+ # ticks left and the average time needed to process a tick.
139
+ # Returns nil if the Run is completed, or if the tick_count or tick_total is
140
+ # zero.
141
+ #
142
+ # @return [Time] the estimated time the Run will finish.
143
+ def estimated_completion_time
144
+ return if completed? || tick_count == 0 || tick_total.to_i == 0
145
+
146
+ processed_per_second = (tick_count.to_f / time_running)
147
+ ticks_left = (tick_total - tick_count)
148
+ seconds_to_finished = ticks_left / processed_per_second
149
+ Time.now + seconds_to_finished
150
+ end
151
+
152
+ # Cancels a Run.
153
+ #
154
+ # If the Run is paused, it will transition directly to cancelled, since the
155
+ # Task is not being performed. In this case, the ended_at timestamp
156
+ # will be updated.
157
+ #
158
+ # If the Run is not paused, the Run will transition to cancelling.
159
+ #
160
+ # If the Run is already cancelling, and has last been updated more than 5
161
+ # minutes ago, it will transition to cancelled, and the ended_at timestamp
162
+ # will be updated.
163
+ def cancel
164
+ if paused? || stuck?
165
+ update!(status: :cancelled, ended_at: Time.now)
166
+ else
167
+ cancelling!
168
+ end
169
+ end
170
+
171
+ # Returns whether a Run is stuck, which is defined as having a status of
172
+ # cancelling, and not having been updated in the last 5 minutes.
173
+ #
174
+ # @return [Boolean] whether the Run is stuck.
175
+ def stuck?
176
+ cancelling? && updated_at <= 5.minutes.ago
177
+ end
178
+ end
179
+ private_constant :Run
180
+ end