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,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d591c21fb76efef72185bf183b0f4b0bc0a5b97a2606e083f6fbfc11325e029b
4
+ data.tar.gz: 6195855524490ebc663f1970134b6aed2abe7887345b6c17d2d0dccea9d81fb4
5
+ SHA512:
6
+ metadata.gz: 3fa1e498d50830521cabeb8babda612a9762438cd1fdccf5cecf776ae069ec85cdbaedbded208bdfc1e72479b69ffb76a39b999e4c2f8d810811dc8672f0b5cc
7
+ data.tar.gz: 2d9aa8e0c134baa3864f2cd5e9fe53944a5d13a89c21caab26dcd394b3eb8845af62397f2d1be880c9f27d71cbe77db3865d16c7da53f0b70cc64cc7bf4153a4
@@ -0,0 +1,316 @@
1
+ # MaintenanceTasks
2
+
3
+ A Rails engine for queuing and managing maintenance tasks.
4
+
5
+ ## Table of Contents
6
+ * [Installation](#installation)
7
+ * [Usage](#usage)
8
+ * [Creating a Task](#creating-a-task)
9
+ * [Considerations when writing Tasks](#considerations-when-writing-tasks)
10
+ * [Writing tests for a Task](#writing-tests-for-a-task)
11
+ * [Running a Task](#running-a-task)
12
+ * [Monitoring your Task's status](#monitoring-your-tasks-status)
13
+ * [How Maintenance Tasks runs a Task](#how-maintenance-tasks-runs-a-task)
14
+ * [Help! My Task is stuck](#help-my-task-is-stuck)
15
+ * [Configuring the gem](#configuring-the-gem)
16
+ * [Customizing the error handler](#customizing-the-error-handler)
17
+ * [Customizing the maintenance tasks module](#customizing-the-maintenance-tasks-module)
18
+ * [Customizing the underlying job class](#customizing-the-underlying-job-class)
19
+ * [Customizing the rate at which task progress gets updated](#customizing-the-rate-at-which-task-progress-gets-updated)
20
+ * [Upgrading](#upgrading)
21
+ * [Contributing](#contributing)
22
+ * [Releasing new versions](#releasing-new-versions)
23
+
24
+ ## Installation
25
+
26
+ Add this line to your application's Gemfile:
27
+
28
+ ```ruby
29
+ gem 'maintenance_tasks'
30
+ ```
31
+
32
+ And then execute:
33
+
34
+ ```bash
35
+ $ bundle
36
+ $ rails generate maintenance_tasks:install
37
+ ```
38
+
39
+ The generator creates and runs a migration to add the necessary table to your
40
+ database. It also mounts Maintenance Tasks in your `config/routes.rb`. By
41
+ default the web UI can be accessed in the new `/maintenance_tasks` path.
42
+
43
+ ## Usage
44
+
45
+ ### Creating a Task
46
+
47
+ A generator is provided to create tasks. Generate a new task by running:
48
+
49
+ ```bash
50
+ $ rails generate maintenance_tasks:task update_posts
51
+ ```
52
+
53
+ This creates the task file `app/tasks/maintenance/update_posts_task.rb`.
54
+
55
+ The generated task is a subclass of `MaintenanceTasks::Task` that implements:
56
+
57
+ * `collection`: return an Active Record Relation or an Array to be iterated
58
+ over.
59
+ * `process`: do the work of your maintenance task on a single record
60
+ * `count`: return the number of rows that will be iterated over (optional, to be
61
+ able to show progress)
62
+
63
+ Example:
64
+
65
+ ```ruby
66
+ # app/tasks/maintenance/update_posts_task.rb
67
+ module Maintenance
68
+ class UpdatePostsTask < MaintenanceTasks::Task
69
+ def collection
70
+ Post.all
71
+ end
72
+
73
+ def count
74
+ collection.count
75
+ end
76
+
77
+ def process(post)
78
+ post.update!(content: 'New content!')
79
+ end
80
+ end
81
+ end
82
+ ```
83
+
84
+ #### Considerations when writing Tasks
85
+
86
+ MaintenanceTasks relies on the queue adapter configured for your application to
87
+ run the job which is processing your Task. The guidelines for writing Task may
88
+ depend on the queue adapter but in general, you should follow these rules:
89
+
90
+ * Duration of `Task#process`: processing a single element of the collection
91
+ should take less than 25 seconds, or the duration set as a timeout for Sidekiq
92
+ or the queue adapter configured in your application. It allows the Task to be
93
+ safely interrupted and resumed.
94
+ * Idempotency of `Task#process`: it should be safe to run `process` multiple
95
+ times for the same element of the collection. Read more in [this Sidekiq best
96
+ practice][sidekiq-idempotent]. It's important if the Task errors and you run
97
+ it again, because the same element that errored the Task may well be processed
98
+ again. It especially matters in the situation described above, when the
99
+ iteration duration exceeds the timeout: if the job is re-enqueued, multiple
100
+ elements may be processed again.
101
+
102
+ [sidekiq-idempotent]: https://github.com/mperham/sidekiq/wiki/Best-Practices#2-make-your-job-idempotent-and-transactional
103
+
104
+ ### Writing tests for a Task
105
+
106
+ The task generator will also create a test file for your task in the folder
107
+ `test/tasks/maintenance/`. At a minimum, it's recommended that the `#process`
108
+ method in your task be tested. You may also want to test the `#collection` and
109
+ `#count` methods for your task if they are sufficiently complex.
110
+
111
+ Example:
112
+
113
+ ```ruby
114
+ # test/tasks/maintenance/update_posts_task_test.rb
115
+
116
+ require 'test_helper'
117
+
118
+ module Maintenance
119
+ class UpdatePostsTaskTest < ActiveSupport::TestCase
120
+ test "#process performs a task iteration" do
121
+ post = Post.new
122
+
123
+ Maintenance::UpdatePostsTask.new.process(post)
124
+
125
+ assert_equal 'New content!', post.content
126
+ end
127
+ end
128
+ end
129
+ ```
130
+
131
+ ### Running a Task
132
+
133
+ You can run your new Task by accessing the Web UI and clicking on "Run".
134
+
135
+ Alternatively, you can run your Task in the command line:
136
+
137
+ ```bash
138
+ $ bundle exec maintenance_tasks perform Maintenance::UpdatePostsTask
139
+ ```
140
+
141
+ You can also run a Task in Ruby by sending `run` with a Task name to a Runner
142
+ instance:
143
+
144
+ ```ruby
145
+ MaintenanceTasks::Runner.new.run('Maintenance::UpdatePostsTask')
146
+ ```
147
+
148
+ ### Monitoring your Task's status
149
+
150
+ The web UI will provide updates on the status of your Task. Here are the states
151
+ a Task can be in:
152
+
153
+ * **new**: A Task that has not yet been run.
154
+ * **enqueued**: A Task that is waiting to be performed after a user has
155
+ instructed it to run.
156
+ * **running**: A Task that is currently being performed by a job worker.
157
+ * **pausing**: A Task that was paused by a user, but needs to finish work
158
+ before stopping.
159
+ * **paused**: A Task that was paused by a user and is not performing. It can be
160
+ resumed.
161
+ * **interrupted**: A Task that has been momentarily interrupted by the job
162
+ infrastructure.
163
+ * **cancelling**: A Task that was cancelled by a user, but needs to finish work
164
+ before stopping.
165
+ * **cancelled**: A Task that was cancelled by a user and is not performing. It
166
+ cannot be resumed.
167
+ * **succeeded**: A Task that finished successfully.
168
+ * **errored**: A Task that encountered an unhandled exception while performing.
169
+
170
+ ### How Maintenance Tasks runs a Task
171
+
172
+ Maintenance tasks can be running for a long time, and the purpose of the gem is
173
+ to make it easy to continue running tasks through deploys, [Kubernetes Pod
174
+ scheduling][k8s-scheduling], [Heroku dyno restarts][heroku-cycles] or other
175
+ infrastructure or code changes.
176
+
177
+ [k8s-scheduling]: https://kubernetes.io/docs/concepts/scheduling-eviction/
178
+ [heroku-cycles]: https://www.heroku.com/dynos/lifecycle
179
+
180
+ This means a Task can safely be interrupted, re-enqueued and resumed without any
181
+ intervention at the end of an iteration, after the `process` method returns.
182
+
183
+ By default, a running Task will be interrupted after running for more 5 minutes.
184
+ This is [configured in the `job-iteration` gem][max-job-runtime] and can be
185
+ tweaked in an initializer if necessary.
186
+
187
+ [max-job-runtime]: https://github.com/Shopify/job-iteration/blob/master/guides/best-practices.md#max-job-runtime
188
+
189
+ Running tasks will also be interrupted and re-enqueued when needed. For example
190
+ [when Sidekiq workers shuts down for a deploy][sidekiq-deploy]:
191
+
192
+ [sidekiq-deploy]: https://github.com/mperham/sidekiq/wiki/Deployment
193
+
194
+ * When Sidekiq receives a TSTP or TERM signal, it will consider itself to be
195
+ stopping.
196
+ * When Sidekiq is stopping, JobIteration stops iterating over the enumerator.
197
+ The position in the iteration is saved, a new job is enqueued to resume work,
198
+ and the Task is marked as interrupted.
199
+
200
+ When Sidekiq is stopping, it will give workers 25 seconds to finish before
201
+ forcefully terminating them (this is the default but can be configured with the
202
+ `--timeout` option). Before the worker threads are terminated, Sidekiq will try
203
+ to re-enqueue the job so your Task will be resumed. However, the position in the
204
+ collection won't be persisted so at least one iteration may run again.
205
+
206
+ #### Help! My Task is stuck
207
+
208
+ Finally, if the queue adapter configured for your application doesn't have this
209
+ property, or if Sidekiq crashes, is forcefully terminated, or is unable to
210
+ re-enqueue the jobs that were in progress, the Task may be in a seemingly stuck
211
+ situation where it appears to be running but is not. In that situation, pausing
212
+ or cancelling it will not result in the Task being paused or cancelled, as the
213
+ Task will get stuck in a state of `pausing` or `cancelling`. As a work-around,
214
+ if a Task is `cancelling` for more than 5 minutes, you will be able to cancel it
215
+ for good, which will just mark it as cancelled, allowing you to run it again.
216
+
217
+ ### Configuring the gem
218
+
219
+ There are a few configurable options for the gem. Custom configurations should
220
+ be placed in a `maintenance_tasks.rb` initializer.
221
+
222
+ #### Customizing the error handler
223
+
224
+ Exceptions raised while a Task is performing are rescued and information about
225
+ the error is persisted and visible in the UI.
226
+
227
+ If your application uses Bugsnag to monitor errors, the gem will automatically
228
+ notify Bugsnag of any errors raised while a Task is performing.
229
+
230
+ If you want to integrate with another exception monitoring service or customize
231
+ error handling, a callback can be defined:
232
+
233
+ ```ruby
234
+ # config/initializers/maintenance_tasks.rb
235
+ MaintenanceTasks.error_handler = ->(error) { MyErrorMonitor.notify(error) }
236
+ ```
237
+
238
+ #### Customizing the maintenance tasks module
239
+
240
+ `MaintenanceTasks.tasks_module` can be configured to define the module in which
241
+ tasks will be placed.
242
+
243
+ ```ruby
244
+ # config/initializers/maintenance_tasks.rb
245
+ MaintenanceTasks.tasks_module = 'TaskModule'
246
+ ```
247
+
248
+ If no value is specified, it will default to `Maintenance`.
249
+
250
+ #### Customizing the underlying job class
251
+
252
+ `MaintenanceTasks.job` can be configured to define a Job class for your tasks to
253
+ use. This is a global configuration, so this Job class will be used across all
254
+ maintenance tasks in your application.
255
+
256
+ ```ruby
257
+ # config/initializers/maintenance_tasks.rb
258
+ MaintenanceTasks.job = 'CustomTaskJob'
259
+
260
+ # app/jobs/custom_task_job.rb
261
+ class CustomTaskJob < MaintenanceTasks::TaskJob
262
+ queue_as :low_priority
263
+ end
264
+ ```
265
+
266
+ The Job class **must inherit** from `MaintenanceTasks::TaskJob`.
267
+
268
+ Note that `retry_on` is not supported for custom Job
269
+ classes, so failed jobs cannot be retried.
270
+
271
+ #### Customizing the rate at which task progress gets updated
272
+
273
+ `MaintenanceTasks.ticker_delay` can be configured to customize how frequently
274
+ task progress gets persisted to the database. It can be a `Numeric` value or an
275
+ `ActiveSupport::Duration` value.
276
+
277
+ ```ruby
278
+ # config/initializers/maintenance_tasks.rb
279
+ MaintenanceTasks.ticker_delay = 2.seconds
280
+ ```
281
+
282
+ If no value is specified, it will default to 1 second.
283
+
284
+ ## Upgrading
285
+
286
+ Use bundler to check for and upgrade to newer versions. After installing a new
287
+ version, re-run the install command:
288
+
289
+ ```bash
290
+ $ rails generate maintenance_tasks:install
291
+ ```
292
+
293
+ This ensures that new migrations are installed and run as well.
294
+
295
+ ## Contributing
296
+
297
+ Would you like to report an issue or contribute with code? We accept issues and
298
+ pull requests. You can find the contribution guidelines on
299
+ [CONTRIBUTING.md][contributing].
300
+
301
+ [contributing]: https://github.com/Shopify/maintenance_tasks/blob/main/.github/CONTRIBUTING.md
302
+
303
+ ## Releasing new versions
304
+
305
+ This gem is published to packagecloud. The procedure to publish a new version:
306
+
307
+ * Update `spec.version` in `maintenance_tasks.gemspec`.
308
+ * Run `bundle install` to bump the `Gemfile.lock` version of the gem.
309
+ * Open a PR and merge on approval.
310
+ * Create a [release on GitHub][release] with a version number that matches the
311
+ version defined in the gemspec.
312
+ * Deploy via [Shipit][shipit] and see the new version on
313
+ <https://rubygems.org/gems/maintenance_tasks>.
314
+
315
+ [release]: https://help.github.com/articles/creating-releases/
316
+ [shipit]: https://shipit.shopify.io/shopify/maintenance_tasks/rubygems
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+ begin
3
+ require 'bundler/setup'
4
+ rescue LoadError
5
+ puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
6
+ end
7
+
8
+ require 'rdoc/task'
9
+ RDoc::Task.new(:rdoc) do |rdoc|
10
+ rdoc.rdoc_dir = 'rdoc'
11
+ rdoc.title = 'MaintenanceTasks'
12
+ rdoc.options << '--line-numbers'
13
+ rdoc.rdoc_files.include('README.md')
14
+ rdoc.rdoc_files.include('lib/**/*.rb')
15
+ end
16
+
17
+ APP_RAKEFILE = File.expand_path('test/dummy/Rakefile', __dir__)
18
+ load('rails/tasks/engine.rake')
19
+
20
+ load('rails/tasks/statistics.rake')
21
+
22
+ require 'bundler/gem_tasks'
23
+
24
+ require 'rubocop/rake_task'
25
+ RuboCop::RakeTask.new
26
+
27
+ task(test: 'app:test')
28
+ task('test:system' => 'app:test:system')
29
+ task(default: ['db:setup', 'test', 'test:system', 'rubocop'])
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Base class for all controllers used by this engine.
5
+ #
6
+ # @api private
7
+ class ApplicationController < ActionController::Base
8
+ include Pagy::Backend
9
+
10
+ BULMA_CDN = 'https://cdn.jsdelivr.net'
11
+
12
+ content_security_policy do |policy|
13
+ policy.style_src(BULMA_CDN)
14
+ policy.frame_ancestors(:self)
15
+ end
16
+
17
+ before_action do
18
+ request.content_security_policy_nonce_generator ||=
19
+ ->(_request) { SecureRandom.base64(16) }
20
+ request.content_security_policy_nonce_directives = ['style-src']
21
+ end
22
+
23
+ protect_from_forgery with: :exception
24
+ end
25
+ private_constant :ApplicationController
26
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Class communicates with the Run model to persist info related to task runs.
5
+ # It defines actions for creating and pausing runs.
6
+ #
7
+ # @api private
8
+ class RunsController < ApplicationController
9
+ before_action :set_run, except: :index
10
+
11
+ # Shows a full list of Runs.
12
+ def index
13
+ query = Run.all.order(id: :desc)
14
+ if params[:task_name].present?
15
+ task_name = Run.sanitize_sql_like(params[:task_name])
16
+ query = query.where('task_name LIKE ?', "%#{task_name}%")
17
+ end
18
+ @pagy, @runs = pagy(query)
19
+ end
20
+
21
+ # Updates a Run status to paused.
22
+ def pause
23
+ @run.pausing!
24
+ redirect_to(task_path(@run.task_name))
25
+ rescue ActiveRecord::RecordInvalid => error
26
+ redirect_to(task_path(@run.task_name), alert: error.message)
27
+ end
28
+
29
+ # Updates a Run status to cancelling.
30
+ def cancel
31
+ @run.cancel
32
+ redirect_to(task_path(@run.task_name))
33
+ rescue ActiveRecord::RecordInvalid => error
34
+ redirect_to(task_path(@run.task_name), alert: error.message)
35
+ end
36
+
37
+ private
38
+
39
+ def set_run
40
+ @run = Run.find(params.fetch(:id))
41
+ end
42
+ end
43
+ private_constant :RunsController
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MaintenanceTasks
4
+ # Class handles rendering the maintenance_tasks page in the host application.
5
+ # It makes data about available, enqueued, performing, and completed
6
+ # tasks accessible to the views so it can be displayed in the UI.
7
+ #
8
+ # @api private
9
+ class TasksController < ApplicationController
10
+ before_action :set_refresh, only: [:index, :show]
11
+
12
+ # Renders the maintenance_tasks/tasks page, displaying
13
+ # available tasks to users, grouped by category.
14
+ def index
15
+ @available_tasks = TaskData.available_tasks.group_by(&:category)
16
+ end
17
+
18
+ # Renders the page responsible for providing Task actions to users.
19
+ # Shows running and completed instances of the Task.
20
+ def show
21
+ @task = TaskData.find(params.fetch(:id))
22
+ @pagy, @previous_runs = pagy(@task.previous_runs)
23
+ end
24
+
25
+ # Runs a given Task and redirects to the Task page.
26
+ def run
27
+ task = Runner.new.run(name: params.fetch(:id))
28
+ redirect_to(task_path(task))
29
+ rescue ActiveRecord::RecordInvalid => error
30
+ redirect_to(task_path(error.record.task_name), alert: error.message)
31
+ rescue Runner::EnqueuingError => error
32
+ redirect_to(task_path(error.run.task_name), alert: error.message)
33
+ end
34
+
35
+ private
36
+
37
+ def set_refresh
38
+ @refresh = 3
39
+ end
40
+ end
41
+ private_constant :TasksController
42
+ end