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,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>