maintenance_tasks 1.0.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 (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