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