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,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # This class is responsible for running a given Task.
5
+ class Runner
6
+ # Exception raised when a Task Job couldn't be enqueued.
7
+ class EnqueuingError < StandardError
8
+ # Initializes a Enqueuing Error.
9
+ #
10
+ # @param run [Run] the Run which failed to be enqueued.
11
+ # @return [EnqueuingError] an Enqueuing Error instance.
12
+ def initialize(run)
13
+ super("The job to perform #{run.task_name} could not be enqueued")
14
+ @run = run
15
+ end
16
+
17
+ attr_reader :run
18
+ end
19
+
20
+ # Runs a Task.
21
+ #
22
+ # This method creates a Run record for the given Task name and enqueues the
23
+ # Run.
24
+ #
25
+ # @param name [String] the name of the Task to be run.
26
+ #
27
+ # @return [Task] the Task that was run.
28
+ #
29
+ # @raise [EnqueuingError] if an error occurs while enqueuing the Run.
30
+ # @raise [ActiveRecord::RecordInvalid] if validation errors occur while
31
+ # creating the Run.
32
+ def run(name:)
33
+ run = Run.active.find_by(task_name: name) || Run.new(task_name: name)
34
+
35
+ run.enqueued!
36
+ enqueue(run)
37
+ Task.named(name)
38
+ end
39
+
40
+ private
41
+
42
+ def enqueue(run)
43
+ unless MaintenanceTasks.job.perform_later(run)
44
+ raise "The job to perform #{run.task_name} could not be enqueued. "\
45
+ 'Enqueuing has been prevented by a callback.'
46
+ end
47
+ rescue => error
48
+ run.persist_error(error)
49
+ raise EnqueuingError, run
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Class that represents the data related to a Task. Such information can be
4
+ # sourced from a Task or from existing Run records for a Task that was since
5
+ # deleted.
6
+ #
7
+ # Instances of this class replace a Task class instance in cases where we
8
+ # don't need the actual Task subclass.
9
+ #
10
+ # @api private
11
+ class TaskData
12
+ class << self
13
+ # Initializes a Task Data by name, raising if the Task does not exist.
14
+ #
15
+ # For the purpose of this method, a Task does not exist if it's deleted
16
+ # and doesn't have a Run. While technically, it could have existed and
17
+ # been deleted since, if it never had a Run we may as well consider it
18
+ # non-existent since we don't have interesting data to show.
19
+ #
20
+ # @param name [String] the name of the Task subclass.
21
+ # @return [TaskData] a Task Data instance.
22
+ # @raise [Task::NotFoundError] if the Task does not exist and doesn't have
23
+ # a Run.
24
+ def find(name)
25
+ task_data = new(name)
26
+ task_data.last_run || Task.named(name)
27
+ task_data
28
+ end
29
+
30
+ # Returns a list of sorted Task Data objects that represent the
31
+ # available Tasks.
32
+ #
33
+ # Tasks are sorted by category, and within a category, by Task name.
34
+ # Determining a Task's category require its latest Run record.
35
+ # To optimize calls to the database, a single query is done to get the
36
+ # last Run for each Task, and Task Data instances are initialized with
37
+ # these last_run values.
38
+ #
39
+ # @return [Array<TaskData>] the list of Task Data.
40
+ def available_tasks
41
+ task_names = Task.available_tasks.map(&:name)
42
+ available_task_runs = Run.where(task_name: task_names)
43
+ last_runs = Run.where(
44
+ id: available_task_runs.select('MAX(id) as id').group(:task_name)
45
+ )
46
+
47
+ task_names.map do |task_name|
48
+ last_run = last_runs.find { |run| run.task_name == task_name }
49
+ TaskData.new(task_name, last_run)
50
+ end.sort_by!(&:name)
51
+ end
52
+ end
53
+
54
+ # Initializes a Task Data with a name and optionally a last_run.
55
+ #
56
+ # @param name [String] the name of the Task subclass.
57
+ # @param last_run [MaintenanceTasks::Run] optionally, a Run record to
58
+ # set for the Task.
59
+ def initialize(name, last_run = :none_passed)
60
+ @name = name
61
+ @last_run = last_run unless last_run == :none_passed
62
+ end
63
+
64
+ # @return [String] the name of the Task.
65
+ attr_reader :name
66
+
67
+ alias_method :to_s, :name
68
+
69
+ # The Task's source code.
70
+ #
71
+ # @return [String] the contents of the file which defines the Task.
72
+ # @return [nil] if the Task file was deleted.
73
+ def code
74
+ return if deleted?
75
+ task = Task.named(name)
76
+ file = task.instance_method(:collection).source_location.first
77
+ File.read(file)
78
+ end
79
+
80
+ # Retrieves the latest Run associated with the Task.
81
+ #
82
+ # @return [MaintenanceTasks::Run] the Run record.
83
+ # @return [nil] if there are no Runs associated with the Task.
84
+ def last_run
85
+ return @last_run if defined?(@last_run)
86
+ @last_run = runs.first
87
+ end
88
+
89
+ # Returns the set of Run records associated with the Task previous to the
90
+ # last Run. This collection represents a historic of past Runs for
91
+ # information purposes, since the base for Task Data information comes
92
+ # primarily from the last Run.
93
+ #
94
+ # @return [ActiveRecord::Relation<MaintenanceTasks::Run>] the relation of
95
+ # record previous to the last Run.
96
+ def previous_runs
97
+ return Run.none unless last_run
98
+ runs.where.not(id: last_run.id)
99
+ end
100
+
101
+ # @return [Boolean] whether the Task has been deleted.
102
+ def deleted?
103
+ Task.named(name)
104
+ false
105
+ rescue Task::NotFoundError
106
+ true
107
+ end
108
+
109
+ # The Task status. It returns the status of the last Run, if present. If the
110
+ # Task does not have any Runs, the Task status is `new`.
111
+ #
112
+ # @return [String] the Task status.
113
+ def status
114
+ last_run&.status || 'new'
115
+ end
116
+
117
+ # Retrieves the Task's category, which is one of active, new, or completed.
118
+ #
119
+ # @return [Symbol] the category of the Task.
120
+ def category
121
+ if last_run.present? && last_run.active?
122
+ :active
123
+ elsif last_run.nil?
124
+ :new
125
+ else
126
+ :completed
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def runs
133
+ Run.where(task_name: name).order(created_at: :desc)
134
+ end
135
+ end
136
+ private_constant :TaskData
137
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # This class encapsulates the logic behind updating the tick counter.
5
+ #
6
+ # It's initialized with a duration for the throttle, and a block to persist
7
+ # the number of ticks to increment.
8
+ #
9
+ # When +tick+ is called, the block will be called with the increment,
10
+ # provided the duration since the last update (or initialization) has been
11
+ # long enough.
12
+ #
13
+ # To not lose any increments, +persist+ should be used, which may call the
14
+ # block with any leftover ticks.
15
+ #
16
+ # @api private
17
+ class Ticker
18
+ # Creates a Ticker that will call the block each time +tick+ is called,
19
+ # unless the tick is being throttled.
20
+ #
21
+ # @param throttle_duration [ActiveSupport::Duration, Numeric] Duration
22
+ # since initialization or last call that will cause a throttle.
23
+ # @yieldparam ticks [Integer] the increment in ticks to be persisted.
24
+ def initialize(throttle_duration, &persist)
25
+ @throttle_duration = throttle_duration
26
+ @persist = persist
27
+ @last_persisted = Time.now
28
+ @ticks_recorded = 0
29
+ end
30
+
31
+ # Increments the tick count by one, and may persist the new value if the
32
+ # threshold duration has passed since initialization or the tick count was
33
+ # last persisted.
34
+ def tick
35
+ @ticks_recorded += 1
36
+ persist if persist?
37
+ end
38
+
39
+ # Persists the tick increments by calling the block passed to the
40
+ # initializer. This is idempotent in the sense that calling it twice in a
41
+ # row will call the block at most once (if it had been throttled).
42
+ def persist
43
+ return if @ticks_recorded == 0
44
+ now = Time.now
45
+ duration = now - @last_persisted
46
+ @last_persisted = now
47
+ @persist.call(@ticks_recorded, duration)
48
+ @ticks_recorded = 0
49
+ end
50
+
51
+ private
52
+
53
+ def persist?
54
+ Time.now - @last_persisted >= @throttle_duration
55
+ end
56
+ end
57
+ private_constant :Ticker
58
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base class that is inherited by the host application's task classes.
5
+ class Task
6
+ extend ActiveSupport::DescendantsTracker
7
+
8
+ class NotFoundError < NameError; end
9
+
10
+ class << self
11
+ # Finds a Task with the given name.
12
+ #
13
+ # @param name [String] the name of the Task to be found.
14
+ #
15
+ # @return [Task] the Task with the given name.
16
+ #
17
+ # @raise [NotFoundError] if a Task with the given name does not exist.
18
+ def named(name)
19
+ name.constantize
20
+ rescue NameError
21
+ raise NotFoundError.new("Task #{name} not found.", name)
22
+ end
23
+
24
+ # Returns a list of concrete classes that inherit from the Task
25
+ # superclass.
26
+ #
27
+ # @return [Array<Class>] the list of classes.
28
+ def available_tasks
29
+ load_constants
30
+ descendants
31
+ end
32
+
33
+ private
34
+
35
+ def load_constants
36
+ namespace = MaintenanceTasks.tasks_module.safe_constantize
37
+ return unless namespace
38
+ namespace.constants.map { |constant| namespace.const_get(constant) }
39
+ end
40
+ end
41
+
42
+ # Placeholder method to raise in case a subclass fails to implement the
43
+ # expected instance method.
44
+ #
45
+ # @raise [NotImplementedError] with a message advising subclasses to
46
+ # implement an override for this method.
47
+ def collection
48
+ raise NotImplementedError,
49
+ "#{self.class.name} must implement `collection`."
50
+ end
51
+
52
+ # Placeholder method to raise in case a subclass fails to implement the
53
+ # expected instance method.
54
+ #
55
+ # @param _item [Object] the current item from the enumerator being iterated.
56
+ #
57
+ # @raise [NotImplementedError] with a message advising subclasses to
58
+ # implement an override for this method.
59
+ def process(_item)
60
+ raise NotImplementedError,
61
+ "#{self.class.name} must implement `process`."
62
+ end
63
+
64
+ # Total count of iterations to be performed.
65
+ #
66
+ # Tasks override this method to define the total amount of iterations
67
+ # expected at the start of the run. Return +nil+ if the amount is
68
+ # undefined, or counting would be prohibitive for your database.
69
+ #
70
+ # @return [Integer, nil]
71
+ def count
72
+ end
73
+
74
+ # Convenience method to allow tasks define enumerators with cursors for
75
+ # compatibility with Job Iteration.
76
+ #
77
+ # @return [JobIteration::EnumeratorBuilder] instance of an enumerator
78
+ # builder available to tasks.
79
+ def enumerator_builder
80
+ JobIteration.enumerator_builder.new(nil)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+ module MaintenanceTasks
3
+ # Custom validator class responsible for ensuring that transitions between
4
+ # Run statuses are valid.
5
+ #
6
+ # @api private
7
+ class RunStatusValidator < ActiveModel::Validator
8
+ # Valid status transitions a Run can make.
9
+ VALID_STATUS_TRANSITIONS = {
10
+ # enqueued -> running occurs when the task starts performing.
11
+ # enqueued -> pausing occurs when the task is paused before starting.
12
+ # enqueued -> cancelling occurs when the task is cancelled
13
+ # before starting.
14
+ # enqueued -> errored occurs when the task job fails to be enqueued.
15
+ 'enqueued' => ['running', 'pausing', 'cancelling', 'errored'],
16
+ # pausing -> paused occurs when the task actually halts performing and
17
+ # occupies a status of paused.
18
+ # pausing -> cancelling occurs when the user cancels a task immediately
19
+ # after it was paused, such that the task had not actually halted yet.
20
+ # pausing -> succeeded occurs when the task completes immediately after
21
+ # being paused. This can happen if the task is on its last iteration
22
+ # when it is paused, or if the task is paused after enqueue but has
23
+ # nothing in its collection to process.
24
+ # pausing -> errored occurs when the job raises an exception after the
25
+ # user has paused it.
26
+ 'pausing' => ['paused', 'cancelling', 'succeeded', 'errored'],
27
+ # cancelling -> cancelled occurs when the task actually halts performing
28
+ # and occupies a status of cancelled.
29
+ # cancelling -> succeeded occurs when the task completes immediately after
30
+ # being cancelled. See description for pausing -> succeeded.
31
+ # cancelling -> errored occurs when the job raises an exception after the
32
+ # user has cancelled it.
33
+ 'cancelling' => ['cancelled', 'succeeded', 'errored'],
34
+ # running -> succeeded occurs when the task completes successfully.
35
+ # running -> pausing occurs when a user pauses the task as
36
+ # it's performing.
37
+ # running -> cancelling occurs when a user cancels the task as
38
+ # it's performing.
39
+ # running -> interrupted occurs when the job infra shuts down the task as
40
+ # it's performing.
41
+ # running -> errored occurs when the job raises an exception when running.
42
+ 'running' => [
43
+ 'succeeded',
44
+ 'pausing',
45
+ 'cancelling',
46
+ 'interrupted',
47
+ 'errored',
48
+ ],
49
+ # paused -> enqueued occurs when the task is resumed after being paused.
50
+ # paused -> cancelling when the user cancels the task after it is paused.
51
+ # paused -> cancelled when the user cancels the task after it is paused.
52
+ 'paused' => ['enqueued', 'cancelling', 'cancelled'],
53
+ # interrupted -> running occurs when the task is resumed after being
54
+ # interrupted by the job infrastructure.
55
+ # interrupted -> pausing occurs when the task is paused by the user while
56
+ # it is interrupted.
57
+ # interrupted -> cancelling occurs when the task is cancelled by the user
58
+ # while it is interrupted.
59
+ 'interrupted' => ['running', 'pausing', 'cancelling'],
60
+ }
61
+
62
+ # Validate whether a transition from one Run status
63
+ # to another is acceptable.
64
+ #
65
+ # @param record [MaintenanceTasks::Run] the Run object being validated.
66
+ def validate(record)
67
+ return unless (previous_status, new_status = record.status_change)
68
+
69
+ valid_new_statuses = VALID_STATUS_TRANSITIONS.fetch(previous_status, [])
70
+
71
+ unless valid_new_statuses.include?(new_status)
72
+ add_invalid_status_error(record, previous_status, new_status)
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def add_invalid_status_error(record, previous_status, new_status)
79
+ record.errors.add(
80
+ :status,
81
+ "Cannot transition run from status #{previous_status} to #{new_status}"
82
+ )
83
+ end
84
+ end
85
+ private_constant :RunStatusValidator
86
+ end
@@ -0,0 +1,11 @@
1
+ <nav class="navbar is-dark is-spaced" role="navigation" aria-label="main navigation">
2
+ <div class="navbar-brand">
3
+ <%= link_to 'Maintenance Tasks', root_path, class: 'navbar-item is-size-4 has-text-weight-semibold has-text-danger' %>
4
+ </div>
5
+
6
+ <div id="navbarMenu" class="navbar-menu is-active">
7
+ <div class="navbar-start">
8
+ <%= link_to "Runs", runs_path, class: 'navbar-item' %>
9
+ </div>
10
+ </div>
11
+ </nav>
@@ -0,0 +1,54 @@
1
+ <!DOCTYPE html>
2
+ <html lang="<%= I18n.locale %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+
7
+ <title>
8
+ <% if content_for?(:page_title) %>
9
+ <%= content_for :page_title %> -
10
+ <% end %>
11
+
12
+ Maintenance Tasks
13
+ </title>
14
+
15
+ <%= csrf_meta_tags %>
16
+
17
+ <%=
18
+ stylesheet_link_tag URI.join(controller.class::BULMA_CDN, 'npm/bulma@0.9.1/css/bulma.css'),
19
+ media: :all,
20
+ integrity: 'sha256-67AR2JVjhMZCLVxapLuBSMap5RrXbksv4vlllenHBSE=',
21
+ crossorigin: 'anonymous'
22
+ %>
23
+
24
+ <style nonce="<%= content_security_policy_nonce %>">
25
+ .ruby-comment { color: #6a737d;}
26
+ .ruby-const { color: #e36209; }
27
+ .ruby-embexpr-beg, .ruby-embexpr-end, .ruby-period { color: #24292e; }
28
+ .ruby-ident, .ruby-symbeg { color: #6f42c1; }
29
+ .ruby-ivar, .ruby-cvar, .ruby-gvar, .ruby-int, .ruby-imaginary, .ruby-float, .ruby-rational { color: #005cc5; }
30
+ .ruby-kw { color: #d73a49; }
31
+ .ruby-label, .ruby-tstring-beg, .ruby-tstring-content, .ruby-tstring-end { color: #032f62; }
32
+ </style>
33
+
34
+ <% if defined?(@refresh) %>
35
+ <meta http-equiv="refresh" content="<%= @refresh %>">
36
+ <% end %>
37
+ </head>
38
+
39
+ <body>
40
+ <%= render 'layouts/maintenance_tasks/navbar' %>
41
+
42
+ <section class="section">
43
+ <div class="container">
44
+ <% if notice %>
45
+ <div class="notification is-success"><%= notice %></div>
46
+ <% elsif alert %>
47
+ <div class="notification is-warning"><%= alert %></div>
48
+ <% end %>
49
+
50
+ <%= yield %>
51
+ </div>
52
+ </div>
53
+ </body>
54
+ </html>